diff --git a/README.en.md b/README.en.md index 2a41ea39..602d36b9 100644 --- a/README.en.md +++ b/README.en.md @@ -29,25 +29,26 @@ Supported: Windows x64, Linux x64, and macOS 13+ on Apple Silicon or Intel (`darwin-arm64`, `darwin-x64`). See [macOS](docs/macos.md). > [!IMPORTANT] -> This branch is the **0.4.0 release candidate**, not a published release. Approved acceptance requires -> at least 40% less BM-01 output than OPS 0.3.x; the official reader is a separate comparison. -> BM-03 limits file/discovery schemas to 400 tokens while keeping Bash at startup and reporting the -> full total separately. Final measured evidence and functional CI are both required. -> See [release status and limitations](docs/release-0.4.0.md). Commands using `@latest` below select -> the published package; build and pack this checkout to evaluate the candidate. - -- Native starts with `ops_read_files` and `ops_discover_tools`; the default profile enables the shell row - and publishes `ops_bash` on every platform. Advanced tools activate in the owning agent's next - provider request. +> This branch is the **0.5.0 candidate**, not a published release: the component roster moved to +> preset-mode rows. Commands using `@latest` below select the published package; build and pack this +> checkout to evaluate the candidate. See the [changelog](docs/CHANGELOG.md). + +- The default profile enables the four preset-mode rows: Standard and Cordis publish the resident + OPS file tools, PTC publishes its typed SDK, and Minimal replaces only the Windows shell; + its sessions receive the `ops_discover_tools` entry whenever the discovery row is enabled. A mode row replaces the official + Windows shell with `ops_bash`. Advanced tools activate in the owning agent's next provider request. - The PTC SDK catalogue has 18 APIs: 15 file/document tools, batch, result references and its loader. Only the configured resident or selected APIs are exposed. Native and PTC use separate outputs. - Discover bounded CSV/TSV, DOCX main-body paragraphs and a restricted PDF text layer. Binary DOC, OCR, general PDF rendering, web search and HTML crawling are not implemented. Read the [document-format limits](docs/native-kernel.md) before choosing a reader. -- Seven independently toggled rows preserve saved profile IDs and settings. Per-tool official - replacements are off by default. [Settings](docs/configuration-reference.md) include validated - built-in forms and optional browser pages with an explicit offline carrier migration. -- The shell row is on by default on all three platforms: Windows uses the pinned Bash payload +- Eight independently toggled rows: the four mode rows and discovery start on; Compatibility, + panel and TUI start off. An enabled mode row hides the official tools it replaces (read/glob/grep + plus the Windows pwsh); Compatibility mode keeps the official tools visible. Saved overrides for + the removed `shell`/`file`/`ptc`/`hide-official` rows must be deleted. + [Settings](docs/configuration-reference.md) include validated built-in forms and optional browser + pages with an explicit offline carrier migration. +- The mode rows are on by default on all three platforms: Windows uses the pinned Bash payload declared by the main package — `@dsh-ops/bash-win32-x64@0.2.1` in `optionalDependencies`, so npm installs the independent **bash 5.3.15** (PortableGit `v2.56.0.windows.2`) that ships with ops, and `bashPath` can point `ops_bash` at Git Bash instead; Linux/macOS use the distribution's own diff --git a/README.md b/README.md index ccf70dc3..77408930 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ 以提高token效率为目的,提供新增的ops系列工具并隐藏部分官方工具为方案的插件,低改动面,低侵入性,与其他插件广泛兼容。 -- (仅windows平台)启用与dsh原生bash行为一致的ops_bash,并可选禁用powershell +- (仅 Windows)启用与 dsh 原生 bash 行为一致的 ops_bash;启用模式行时用它替换官方 pwsh,兼容模式则保留官方工具 +- 四个预设模式行分别增强内置 Minimal/Standard/PTC/Cordis preset:Standard 与 Cordis 替换官方 read/glob/grep,PTC 提供类型化 SDK,Minimal 只替换 Windows shell;discovery 行启用时其会话始终获得动态加载入口;兼容模式(默认关闭)可保留全部官方工具 - 启用 DSH 预留的 `tools_addition` 动态工具加载,并提供渐进式纰漏的工具,作为高级功能(如pdf、word读取、超多文件批量修改)的加载平台,大大减少常见特定场景的token消耗量,且不会导致系统提示词膨胀 - 为 Native 模式提供返回紧凑、带行号和更好错误回馈的工具以遏制报错循环,减少调用轮次 - 为 PTC 模式提供一些返回结构化 JSON 值的工具以降低 ai 在 ptc 模式下编写 TypeScript 代码的token总量和心智负担 @@ -92,6 +93,8 @@ npx --yes dsh-ops@latest uninstall --profile desktop 旧版显式 provision 的 `/dsh-ops/` 文件(provision 的 shell,以及早期版本可 provision 的 FastCtx 运行时)需单独清理:`npx --yes dsh-ops@latest uninstall --yes`。已移除的后台组件留下的 `~/.fastctx/` 状态默认不删;需要时显式加 `--purge-fastctx`。 +组件 bundle 现为八行:`mode-minimal`、`mode-standard`、`mode-ptc`、`mode-cordis`、`discovery`、`compat`、`panel`、`tui`。旧的 `shell`、`file`、`ptc`、`hide-official` 行已移除,请删除为它们保存的 profile 覆盖,并按需重设模式开关。 + 原 `dsh-ops/background` 组件(FastCtx 后台任务)已移除。仍保留该行的 profile 照常加载:该行不发布任何工具,只记录一条警告,可直接删除。 ## 可选 OPS 只读侧栏 diff --git a/README.zh.md b/README.zh.md index 7eb4fe02..02e73535 100644 --- a/README.zh.md +++ b/README.zh.md @@ -26,14 +26,13 @@ OPS 为 DSH 增加有界文件与文档工具。Native 模式返回紧凑、带 支持:Windows x64、Linux x64,以及 macOS 13+ 的 Apple Silicon 与 Intel(`darwin-arm64`、`darwin-x64`)。见 [macOS](docs/macos.zh.md)。 > [!IMPORTANT] -> 本分支是 **0.4.0 发布候选**,尚未发布。已批准的验收范围为:BM-01 相比 OPS 0.3.x 输出减少至少 40%,官方读取另列对照;BM-03 对文件/发现 schema 执行 400 token 上限,同时保留 Bash 启动常驻并单列完整总数。最终实测证据与功能 CI 缺一不可。 -> 详情见[发布状态与限制](docs/0.4.0发布候选.md)。下方 `@latest` 命令安装已发布版本;评估候选版本需自行构建本检出目录并打包。 +> 本分支是 **0.5.0 未发布候选**:组件 roster 改为预设模式行。下方 `@latest` 命令安装已发布版本;评估候选版本需自行构建本检出目录并打包,变更见[变更日志](docs/CHANGELOG.md)。 -- Native 默认提供 `ops_read_files` 与 `ops_discover_tools`;三个平台的默认 profile 都启用 shell 行并提供 `ops_bash`。高级工具在所属 agent 的下一次供应商请求中激活。 +- 默认 profile 启用四个预设模式行:Standard/Cordis 发布常驻 OPS 文件工具,PTC 发布类型化 SDK,Minimal 只替换 Windows shell;discovery 行启用时其会话获得 `ops_discover_tools` 动态加载入口;`ops_bash` 由模式行在 Windows 上替换官方 pwsh。高级工具在所属 agent 的下一次供应商请求中激活。 - PTC SDK 目录包含 18 个 API:15 个文件/文档工具,加批处理、结果引用和加载器。仅暴露配置为常驻或已选择的 API;Native 与 PTC 分别提供自己的输出形式。 - 可按需发现有界 CSV/TSV、DOCX 主体段落与受限 PDF 文本层读取。尚不支持二进制 DOC、OCR、通用 PDF 渲染、网页搜索或 HTML 抓取;使用前请查看[文档格式限制](docs/原生内核.md)。 -- 七个组件独立启停,保留已有 profile ID 与配置。逐工具官方替换默认关闭。[设置](docs/配置参考.md)提供严格校验的内置表单;可选浏览器页面需显式执行离线承载行迁移。 -- shell 组件在三个平台默认开启:Windows 使用主包声明的固定 bash 载荷——`optionalDependencies` 中的 `@dsh-ops/bash-win32-x64@0.2.1`,npm 安装时自动拉取 ops 自带的独立 **bash 5.3.15**(PortableGit `v2.56.0.windows.2`),也可用 `bashPath` 把 `ops_bash` 指向 git bash;Linux/macOS 使用发行版自带 bash,可用 `config.bashPath` 覆盖。`dsh-ops bash-runtime install` 保留为安装/修复与选择受管理副本的路径。PowerShell 仍由宿主管理;升级命令先预览标准安装,仅加 `--yes` 才执行。 +- 八个组件独立启停:四个模式行与 discovery 默认开,兼容模式、panel 与 TUI 默认关。启用模式行会自动隐藏被替换的官方工具(read/glob/grep + Windows pwsh),兼容模式开启则保留官方工具。[设置](docs/配置参考.md)提供严格校验的内置表单;可选浏览器页面需显式执行离线承载行迁移。旧 `shell`/`file`/`ptc`/`hide-official` 覆盖需删除。 +- 模式行在三个平台默认开启:Windows 使用主包声明的固定 bash 载荷——`optionalDependencies` 中的 `@dsh-ops/bash-win32-x64@0.2.1`,npm 安装时自动拉取 ops 自带的独立 **bash 5.3.15**(PortableGit `v2.56.0.windows.2`),也可用 `bashPath` 把 `ops_bash` 指向 git bash;Linux/macOS 使用发行版自带 bash,可用 `config.bashPath` 覆盖。`dsh-ops bash-runtime install` 保留为安装/修复与选择受管理副本的路径。PowerShell 仍由宿主管理;升级命令先预览标准安装,仅加 `--yes` 才执行。 OPS 只发布自身作用域内的工具与可选路由提示,不改写共享工具注册表。每次调用均检查权限与取消状态。Windows 受限 Bash 仍存在文档注明的部分约束与运行时兼容性限制。 diff --git a/bench/README.md b/bench/README.md index 1db50ff4..5c9274f5 100644 --- a/bench/README.md +++ b/bench/README.md @@ -2,7 +2,7 @@ ## 测试前的准备 -Linux / macOS:使用当前平台构建的 worker;`setup --dry-run` 会说明跳过 Windows PowerShell pin、PSModulePath、Windows Bash IPC 和随包 Windows shell 检查的原因。当前 shell 行已支持各平台,不要再从早期 Windows-only 默认值推断配置。模型 sweep 仍需自己的 OpenAI 兼容服务与凭据;`tools` 自检不调用模型。 +Linux / macOS:使用当前平台构建的 worker;`setup --dry-run` 会说明跳过 Windows PowerShell pin、PSModulePath、Windows Bash IPC 和随包 Windows shell 检查的原因。组件行在三个平台都受支持,不要再从早期 Windows-only 默认值推断配置。模型 sweep 仍需自己的 OpenAI 兼容服务与凭据;`tools` 自检不调用模型。 Linux / macOS: build the worker for the current host. `setup --dry-run` explains skipped Windows-only checks. `ab`, `compare`/`report`, `panel` and `tools` use the same entry point on every platform. A model sweep needs your own OpenAI-compatible endpoint; the tools self-check needs no model. Every sweep now fingerprints the composed profiles even without `--seal`, recording platform and component configuration in the arm summaries. `enabled` is a configuration fact, not proof of tool readiness; unresolved expressions remain `unknown`. @@ -74,11 +74,11 @@ Model flags set the actual shared route, with the condition patch applied after ### 组件开关 / Component switches ```console -node bench/cli.mjs setup --switch hide-official=all --switch ptc --switch panel=false --dry-run -node bench/cli.mjs setup --switch hide-official=read,grep --switch discovery --switch shell +node bench/cli.mjs setup --switch mode-standard --switch mode-ptc=false --switch compat=false --switch panel=false --dry-run +node bench/cli.mjs setup --switch mode-minimal=false --switch mode-cordis --switch discovery --switch compat ``` -`--switch` 可重复,支持 `hide-official`、`ptc`、`panel`、`discovery`、`shell`;省略值等同 `true`,普通开关也接受 `false`。`hide-official=all` 只选择已有替代实现的 `read,glob,grep`,也可指定不重复的子集或 `false`。只修改 candidate 的 `cordis.patch.yml`;baseline 不写入组件行。每次 setup 只改明确指定的开关,不自动搜索组合。 +`--switch` 可重复,支持 `mode-minimal`、`mode-standard`、`mode-ptc`、`mode-cordis`、`discovery`、`compat`、`panel`、`tui`;省略值等同 `true`。四个模式行与 discovery 默认启用;`compat` 是反向开关:`true` 表示兼容模式开启、官方工具保持可见,因此默认的 `false` 才让已启用模式隐藏被替换的官方工具。只修改 candidate 的 `cordis.patch.yml`;baseline 不写入组件行。每次 setup 只改明确指定的开关,不自动搜索组合。 写入幂等;本地已有相反值需 `--force`,继承的字面配置键会保留。动态表达式、非映射配置、重复行和无法安全编辑的目标形态需手工处理,`--force` 也不会猜测。`--dry-run` 打印请求的行和值,不启动宿主、不修改 profile。 @@ -86,7 +86,7 @@ node bench/cli.mjs setup --switch hide-official=read,grep --switch discovery --s 实际配置写入摘要及封存 `run.json` 的 `arms.cand.switches`,报告头和 panel 显示 candidate 开关。baseline 的空集合与 candidate 的非空集合属于正常 A/B;两份运行的 candidate 集合不同则 compare 退出 `2`,列出差异,不合并 Δ。未求值配置记录 `null`,旧数据缺失开关元数据仍可读取为未知;这些字段是配置事实,不证明组件已经发布到会话。 -Repeat `--switch` to set candidate components. Values default to `true`; booleans also accept `false`. `hide-official=all` expands only the implemented `read,glob,grep` replacements, or select a unique subset. Setup edits only the candidate patch, preserves inherited literal configuration, and changes only named switches. Conflicting local values require `--force`; dynamic or unsafe shapes require manual edits even with force. Dry-run prints the requested values without launching the host or writing profiles. +Repeat `--switch` to set candidate components. Values default to `true`. Supported names are `mode-minimal`, `mode-standard`, `mode-ptc`, `mode-cordis`, `discovery`, `compat`, `panel` and `tui`; the four mode rows and discovery are on by default. `compat` is the inverse switch: `true` turns Compatibility mode on and keeps the official tools visible, so the default `false` lets enabled modes hide the tools they replace. Setup edits only the candidate patch, preserves inherited literal configuration, and changes only named switches. Conflicting local values require `--force`; dynamic or unsafe shapes require manual edits even with force. Dry-run prints the requested values without launching the host or writing profiles. Real setup verifies composed configuration and runs one bounded headless readiness turn, which may call the configured model. Missing successful completion, host errors and `READINESS_BLOCKED` fail setup with the #207 diagnostic before a sweep starts. This does not substitute for Desktop validation. Summaries and sealed `arms.cand.switches` retain effective configuration; report headers and the panel show it. Comparisons reject different candidate switch sets with exit 2, while the baseline's empty set is expected. Unevaluated configuration and historical missing metadata remain unknown, without implying publication readiness. Switch combinations are selected explicitly and are outside the CI gate. diff --git a/bench/lib/environment.mjs b/bench/lib/environment.mjs index f5629b91..e6f62476 100644 --- a/bench/lib/environment.mjs +++ b/bench/lib/environment.mjs @@ -1,6 +1,7 @@ import { parse } from 'yaml' -const COMPONENTS = ['shell', 'file', 'ptc', 'discovery', 'panel', 'tui', 'hide-official'] +// Keep in sync with the component roster in src/components/defaults.ts. +const COMPONENTS = ['mode-minimal', 'mode-standard', 'mode-ptc', 'mode-cordis', 'discovery', 'compat', 'panel', 'tui'] /** Read configuration facts, never evaluate an expression or infer publication readiness. */ export function componentFacts(text) { diff --git a/bench/lib/switches.mjs b/bench/lib/switches.mjs index a7e35234..b1f5705d 100644 --- a/bench/lib/switches.mjs +++ b/bench/lib/switches.mjs @@ -1,7 +1,14 @@ import { parse } from 'yaml' -export const SWITCH_ROWS = Object.freeze(Object.fromEntries(['hide-official', 'ptc', 'panel', 'discovery', 'shell'].map(name => [name, `dsh-ops-${name}`]))) -export const ACTIVATION_KEYS = Object.freeze({ discovery: 'enabled', panel: 'capture', shell: 'publishBashTool' }) +// Component rows of the fallback bundle. The four mode rows publish an enhanced +// built-in preset and start enabled; "compat" is the inverse switch — on keeps the +// official tools visible, off lets enabled mode rows hide what they replace. +export const SWITCH_ROWS = Object.freeze(Object.fromEntries([ + 'mode-minimal', 'mode-standard', 'mode-ptc', 'mode-cordis', 'discovery', 'compat', 'panel', 'tui', +].map(name => [name, `dsh-ops-${name}`]))) +// Legacy activation keys still found in saved profile overrides. Mode rows use the +// row switch (disabled), and Compatibility mode has no configuration key. +export const ACTIVATION_KEYS = Object.freeze({ discovery: 'enabled', panel: 'capture' }) export const parseRows = text => parse(text, { customTags: [{ tag: 'tag:yaml.org,2002:js', resolve: value => ({ expression: value }) }] }) /** Configuration facts only; readiness is established separately by setup's host probe. */ @@ -20,8 +27,6 @@ export function switchesFromDump(text) { const enabled = row.disabled === true || activation === false ? false : knownConfig && (row.disabled === undefined || row.disabled === false) && (activation === undefined || activation === true) ? true : null switches[name] = { enabled } - if (name === 'hide-official') switches[name].replaceTools = Array.isArray(row.config?.replaceTools) - && row.config.replaceTools.every(value => typeof value === 'string') ? [...row.config.replaceTools].sort() : null } return switches } diff --git a/bench/setup/switches.mjs b/bench/setup/switches.mjs index 01fc2622..fe8d5aa5 100644 --- a/bench/setup/switches.mjs +++ b/bench/setup/switches.mjs @@ -1,10 +1,9 @@ import fs from 'node:fs' import path from 'node:path' -import { OFFICIAL_REPLACEMENTS } from '../../lib/official-tool-catalog.js' import { LLM_OVERLAY, planEntryPatch } from './profile.mjs' import { ACTIVATION_KEYS, SWITCH_ROWS, parseRows, switchesFromDump } from '../lib/switches.mjs' -export const SWITCH_USAGE = ' --switch [=value] candidate only; repeat hide-official=all|read,glob,grep|false, ptc, panel, discovery, shell (booleans true/false)' +export const SWITCH_USAGE = ' --switch [=value] candidate only; repeat mode-minimal, mode-standard, mode-ptc, mode-cordis, discovery, compat, panel or tui (booleans true/false; compat=true keeps the official tools visible)' export function extractSwitches(argv) { const rest = [], switches = {} @@ -15,15 +14,8 @@ export function extractSwitches(argv) { const equal = spec.indexOf('='), name = equal < 0 ? spec : spec.slice(0, equal), value = equal < 0 ? 'true' : spec.slice(equal + 1) if (!Object.hasOwn(SWITCH_ROWS, name)) throw new Error(`Unknown switch ${name}; available: ${Object.keys(SWITCH_ROWS).join(', ')}`) if (Object.hasOwn(switches, name)) throw new Error(`Duplicate switch ${name}`) - if (name !== 'hide-official') { - if (!['true', 'false'].includes(value)) throw new Error(`${name} requires true or false`) - switches[name] = { enabled: value === 'true' } - } else { - const names = Object.keys(OFFICIAL_REPLACEMENTS) - const selected = ['all', 'true'].includes(value) ? names : value === 'false' ? [] : value.split(',') - if (selected.some(item => !names.includes(item)) || new Set(selected).size !== selected.length) throw new Error(`hide-official requires all, false or a unique list from ${names.join(', ')}`) - switches[name] = { enabled: value !== 'false', replaceTools: [...selected].sort() } - } + if (!['true', 'false'].includes(value)) throw new Error(`${name} requires true or false`) + switches[name] = { enabled: value === 'true' } } return { argv: rest, switches } } @@ -61,7 +53,7 @@ export function planSwitchPatch(text, switches, { force = false, composed = [] } if (row?.disabled !== undefined && row.disabled !== !wanted.enabled && !force) throw new Error(`${id}.disabled conflicts; use --force`) if (row && row.disabled !== undefined && typeof row.disabled !== 'boolean') throw new Error(`${id}.disabled requires a manual patch`) const config = { ...configMapping(inherited[0]?.config), ...configMapping(row?.config) } - const changes = name === 'hide-official' ? { replaceTools: wanted.replaceTools } : ACTIVATION_KEYS[name] && wanted.enabled ? { [ACTIVATION_KEYS[name]]: true } : {} + const changes = ACTIVATION_KEYS[name] && wanted.enabled ? { [ACTIVATION_KEYS[name]]: true } : {} for (const [key, value] of Object.entries(changes)) { if (row?.config?.[key] !== undefined && JSON.stringify(row.config[key]) !== JSON.stringify(value) && !force) throw new Error(`${id}.${key} conflicts; use --force`) config[key] = value diff --git a/bin/check-release.mjs b/bin/check-release.mjs index 3798e047..5257451c 100644 --- a/bin/check-release.mjs +++ b/bin/check-release.mjs @@ -12,14 +12,14 @@ const root = path.resolve(values.root ?? path.resolve(path.dirname(fileURLToPath const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) const manifest = JSON.parse(fs.readFileSync(path.join(root, 'dsh-plugin.json'), 'utf8')) const components = manifest.contributes?.['x-components'] ?? [] -const declared = components.find(component => component.id === 'dsh-ops-ptc')?.targets ?? [] +const declared = components.find(component => component.id === 'dsh-ops-mode-ptc')?.targets ?? [] const sameSet = (a, b) => JSON.stringify([...a].sort()) === JSON.stringify([...b].sort()) if (!sameSet(declared, PLATFORMS)) throw Error(`Declared scope ${JSON.stringify(declared)} and resolver PLATFORMS ${JSON.stringify(PLATFORMS)} disagree`) // npm os/cpu filters cannot express paired targets. The component's exact targets // and runtime resolver are authoritative; Intel Mac and Windows/Linux arm64 stay rejected. if (!sameSet(pkg.os ?? [], PLATFORMS.map(target => target.split('-')[0]).filter((value, index, all) => all.indexOf(value) === index)) || !sameSet(pkg.cpu ?? [], [...new Set(PLATFORMS.map(target => target.split('-')[1]))])) throw Error('Package os/cpu and exact target declarations disagree') -for (const id of ['dsh-ops-file', 'dsh-ops-shell']) { +for (const id of ['dsh-ops-mode-standard', 'dsh-ops-mode-ptc', 'dsh-ops-mode-cordis', 'dsh-ops-mode-minimal']) { if (!sameSet(components.find(component => component.id === id)?.targets ?? [], PLATFORMS)) throw Error(`${id} target declarations disagree`) } // Restored to the 0.3.2 contract: the Windows bash payload is a declared optional diff --git a/cordis.patch.yml b/cordis.patch.yml index a029d3f9..ddc2a0eb 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -1,27 +1,31 @@ -# Seven stable component IDs. Saved profile row overrides take precedence. -# The shell row is enabled on every supported platform; the Loader keeps the -# platform-defaults expression only for the rows that still need one. -# File, PTC and discovery start enabled. Panel, TUI and official replacements are opt-in. -# Keep the legacy file specifier so name-qualified saved overrides still match. -# The removed background row remains a no-op compatibility export. +# Eight stable component IDs. Saved profile row overrides take precedence. +# The four enhanced-preset mode rows and discovery start enabled; the +# Compatibility fallback, data capture and the TUI integration are opt-in. +# Minimal publishes no routing section, so its row carries only the shell keys. +# Legacy component modules (shell, file, ptc, hide-official, background) remain +# exported but are no longer inserted here: a saved override for one of those +# old specifiers still loads its legacy module, it just cannot match a new row. - insert: - - id: dsh-ops-shell - name: dsh-ops/shell + - id: dsh-ops-mode-minimal + name: dsh-ops/mode-minimal config: promptPolicy: true publishBashTool: true - shellPolicy: advise - allowSystemShellFallback: false - - id: dsh-ops-file - name: dsh-ops/file + - id: dsh-ops-mode-standard + name: dsh-ops/mode-standard config: promptPolicy: true required: false - - id: dsh-ops-ptc - name: dsh-ops/ptc + - id: dsh-ops-mode-ptc + name: dsh-ops/mode-ptc config: required: false alwaysLoaded: [] + - id: dsh-ops-mode-cordis + name: dsh-ops/mode-cordis + config: + promptPolicy: true + required: false - id: dsh-ops-discovery name: dsh-ops/discovery config: @@ -30,6 +34,9 @@ alwaysLoaded: [] required: false denyCredentialReads: true + - id: dsh-ops-compat + name: dsh-ops/compat + disabled: true - id: dsh-ops-panel name: dsh-ops/panel disabled: true @@ -45,8 +52,3 @@ maxSessions: 32 maxActive: 128 recentLimit: 8 - - id: dsh-ops-hide-official - name: dsh-ops/hide-official - disabled: true - config: - replaceTools: [] diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 912a7e01..99c537a4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,38 @@ All notable changes to this package are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] — 2026-10-27 — Preset-mode component roster + +### Breaking changes + +- Replace the seven-row component roster with eight rows: `dsh-ops/mode-minimal`, + `dsh-ops/mode-standard`, `dsh-ops/mode-ptc` and `dsh-ops/mode-cordis` for the built-in + presets, `dsh-ops/discovery` for dynamic tool loading, `dsh-ops/compat` for Compatibility + mode, `dsh-ops/panel` for data inspection and `dsh-ops/tui` for TUI integration. The former + `dsh-ops/shell`, `dsh-ops/file`, `dsh-ops/ptc` and `dsh-ops/hide-official` rows are gone + from the bundle; their modules remain as unsupported legacy exports for old profiles. +- A mode row publishes OPS tools only for sessions composed from its preset. Standard and Cordis + replace the official `read`, `glob` and `grep`; every enabled mode replaces the official + Windows shell (`pwsh`/`pwsh_persistent` with `ops_bash` on Windows). A replaced tool is + hidden only while its OPS counterpart is visible in the same scope. +- `dsh-ops/compat` inverts the removed per-tool `replaceTools` selection: enabling + Compatibility mode keeps every official tool visible while OPS tools stay available, and lifts + hiding without unloading any row. It is off by default; while it is on, token efficiency and + model ability are reduced. +- Windows sessions route command execution through `ops_bash` instead of the official `pwsh` + when a mode row is enabled and the OPS counterpart is visible. +- Restore the OPS icon on every component row. + +### Upgrade + +- Delete saved profile patch overrides for `dsh-ops-shell`, `dsh-ops-file`, `dsh-ops-ptc` and + `dsh-ops-hide-official`; a leftover override names no row in the new bundle and produces a + startup `patch: entry ... not found` warning. Set the mode switches you want afterwards. +- The optional client carrier migration now only switches the profile bundle name. It preserves + every component row, switch and effective configuration; the nested client bundle renames only the + standard-mode row to the package root, so a name-qualified `dsh-ops/mode-standard` override is + refused for manual review instead of being rewritten. + ## [Unreleased] - Add candidate-only benchmark component switches with conservative patch editing, composed-config verification and a bounded readiness turn. Archive effective switches, show them in reports, and reject comparisons across different candidate switch sets (#296). diff --git a/docs/component-reference.md b/docs/component-reference.md index 27e56146..74bfa781 100644 --- a/docs/component-reference.md +++ b/docs/component-reference.md @@ -1,18 +1,27 @@ # Component reference -What each of the seven components publishes, with parameters and permission requirements. +What each of the eight components publishes, with parameters and permission requirements. --- ## Components at a glance | Row `name` | Description | Default | Published tools | | --- | --- | --- | --- | -| `dsh-ops/shell` | Prefer bash as the terminal, with host PowerShell for operations bash cannot cover | On (all platforms) | `ops_bash`; host `pwsh` keeps its own executor | -| `dsh-ops/file` | Bounded Rust file retrieval and text output for the model | On | `ops_read_files`; advanced tools load through discovery | -| `dsh-ops/ptc` | Typed file APIs and progressive SDK in PTC sessions | On | `ops_inspect_local_file`, `ops_result`, `ops_load_sdk`; advanced APIs load through the SDK | +| `dsh-ops/mode-minimal` | Enhanced Minimal preset: dynamic loading and the Windows official shell replacement | On | `ops_bash`; no resident file tool | +| `dsh-ops/mode-standard` | Enhanced Standard preset: resident file surface, official read/glob/grep and Windows shell replacement | On | `ops_read_files`, `ops_inspect_local_file`, `ops_glob`, `ops_grep`, `ops_result`, `ops_bash` | +| `dsh-ops/mode-ptc` | Enhanced PTC preset: typed SDK and Windows shell replacement | On | PTC SDK APIs (the 18-API catalogue), `ops_bash`; no Native file replacement | +| `dsh-ops/mode-cordis` | Enhanced Cordis preset: resident file surface, official read/glob/grep and Windows shell replacement | On | Same as Standard, with its own routing section | | `dsh-ops/discovery` | Advanced Native tools on demand | On | `ops_discover_tools` | +| `dsh-ops/compat` | Compatibility mode: every official tool stays visible | Off | None; the OPS tools of enabled mode rows stay published | | `dsh-ops/panel` | Data capture, off at the switch: new conversations write `/.dsh-ops/panel/.json` plus the viewer `panel.html` | Row off; capture defaults true | None; see the [configuration reference](configuration-reference.md) | | `dsh-ops/tui` | Read-only current-session OPS sidebar | Off | None; see [sidebar guide](tui-panel.md) | -| `dsh-ops/hide-official` | Select read/glob/grep alternatives; unselected official tools remain visible | Off | None | + +The `dsh-ops/discovery` row owns `ops_discover_tools` and the advanced capabilities for every mode. +A mode row publishes only for sessions composed from its built-in preset. It hides the official tools +it replaces — `read`, `glob` and `grep` in Standard and Cordis, and the Windows official shell +(`pwsh`/`pwsh_persistent` → `ops_bash`) in every mode — but only while the OPS counterpart is +visible in the same scope. Compatibility mode keeps the official tools exposed instead. The former +`dsh-ops/shell`, `dsh-ops/file`, `dsh-ops/ptc` and `dsh-ops/hide-official` modules remain as +unsupported legacy exports for old profiles and are not part of the bundle. The former `dsh-ops/background` component (FastCtx background jobs: `ops_run`, `ops_run_background`, `ops_job_output`, `ops_job_list`, `ops_job_kill`) has been removed. A profile row that still names it loads as a no-op: it accepts any config, publishes nothing, and logs one warning that the row can be deleted. @@ -43,9 +52,13 @@ The shared catalogue has 20 entries. The generated PTC SDK contains 18 APIs: 15 --- -## `dsh-ops/file` — file tools +## Preset modes — file tools -Only the bounded multi-file reader `ops_read_files` is resident; `ops_inspect_local_file`, including hex reading, is discoverable. The advanced contracts below are activated through [tool discovery](discovery.md) or explicit always-loaded choices. Routing lists only the tools currently published. +Standard and Cordis publish the resident OPS file surface: `ops_read_files`, +`ops_inspect_local_file`, `ops_glob`, `ops_grep` and `ops_result`. The Minimal preset publishes +no resident file tool; its sessions receive the dynamic-loading entry and activate advanced contracts +through [tool discovery](discovery.md). PTC mode exposes the same capabilities through the typed SDK. +Routing lists only the tools currently published in that scope. File paths resolve relative to the DSH session cwd. `ops_read_files` takes a `files` array of paths with independent 1-based offsets and limits; single-file operations take `path`. The Native reference pager accepts the original reference `root`. No file tool accepts `workspace`, `cwd` or `file_path` aliases. Text tools are UTF-8 first; document tools use their own bounded format/encoding contracts. glob and grep do not traverse symlinks, junctions, reparse points, hidden files or `.git`. @@ -134,9 +147,10 @@ Each reader separates scan, page, display and reference-capture completeness. A --- -## `dsh-ops/ptc` — native batches and result references +## PTC mode — native batches and result references -Available by default, with tools published only in pure PTC sessions. Explicit profile disablement is preserved. +The `dsh-ops/mode-ptc` row is on by default and publishes these tools only in sessions composed from +the PTC preset. Its host row switch preserves explicit disablement and its configured pool. ### `ops_batch` @@ -171,9 +185,11 @@ Bounded session-owned result references: PTC can put object rows; Native file to --- -## `dsh-ops/shell` — terminal and shells +## Preset modes — terminal and shells -Routing: **toolkit → bash → host PowerShell**. +Routing: **toolkit → bash → host PowerShell**. Every enabled mode replaces the Windows official shell +(`pwsh`/`pwsh_persistent`) with `ops_bash` while that counterpart is visible; POSIX sessions already +run the host's own bash. On Windows x64 the install carries the pinned Bash payload as an optional dependency of the main package (`@dsh-ops/bash-win32-x64@0.2.1`, PortableGit `v2.56.0.windows.2`, GNU bash 5.3.15); `bashPath` can select Git Bash instead, and `dsh-ops bash-runtime install` repairs or installs a managed copy when the payload is absent. POSIX hosts use the system Bash and ship no payload. PowerShell stays host-selected. @@ -213,9 +229,13 @@ The plugin renders routing guidance from what it actually published. The excerpt --- -## `dsh-ops/hide-official` — per-tool replacements +## Compatibility mode — official tools stay visible -Disabled by default, with `replaceTools: []`. Explicit selections switch the model to these reviewed read/search workflows. Their names, arguments and results differ from the host tools; calls are never translated or forwarded under an official name. A `run_code` presentation (PTC) is never masked: its transport is not gated, while the selected official tools stay refused in that scope. The replacement contract covers native presentation only. +Standard and Cordis replace the official `read`, `glob` and `grep` with these reviewed read/search +workflows; every enabled mode replaces the Windows official shell. Their names, arguments and results +differ from the host tools; calls are never translated or forwarded under an official name. A +`run_code` presentation (PTC) is never masked: its transport is not gated, while the replaced +official names stay refused in that scope. The replacement contract covers native presentation only. | Official tool | OPS alternative | Reviewed purpose | @@ -229,15 +249,31 @@ Disabled by default, with `replaceTools: []`. Explicit selections switch the mod - **glob:** the host accepts one pattern string and matches a bare basename at any depth, including hidden and ignored files. OPS accepts an array of globs relative to `path`; `*.ts` matches only the top level, so use `**/*.ts` for recursion. OPS supports exclusions and paging, and does not traverse symlinks, reparse points, hidden files or `.git`. The search coverage and ordering are not identical. - **grep:** the host accepts a single glob filter and ripgrep regular expressions. OPS uses Rust regex, an array of relative globs, optional context and output modes, plus bounded paged coverage. Read its completion/truncation fields; a partial scan is not an exhaustive negative result. Host overflow artifacts and OPS paging are different workflows. -All OPS calls keep their own session-cwd base, permission checks and output contracts. Selecting an alternative does not broaden authority. Choose only the workflows that fit the task; leave a tool unselected when its host-specific coverage is needed. +All OPS calls keep their own session-cwd base, permission checks and output contracts. Replacing an official tool does not broaden authority. + +`write`, `edit`, and `str_replace_editor` are deliberately not replaced. OPS write/edit require an observed `expectedRevision` and CAS publication; host write and replace-all edit do not share that contract. A similarly named registration is not evidence of compatibility. Shells, `run_code`, delegation, jobs, media and third-party tools also remain visible without a warning list. -`write`, `edit`, and `str_replace_editor` are deliberately unavailable as selections. OPS write/edit require an observed `expectedRevision` and CAS publication; host write and replace-all edit do not share that contract. A similarly named registration is not evidence of compatibility. Shells, `run_code`, delegation, jobs, media and third-party tools also remain visible without a warning list. +The `dsh-ops/compat` row is **disabled by default** and accepts no configuration. Enabling it keeps +every official tool exposed and callable while OPS tools stay published, and lifts the per-scope +hiding without unloading any row; disabling it restores hiding. There is no per-tool `replaceTools` +selection in this version — the removed `dsh-ops/hide-official` row is a legacy module outside the +bundle, and a saved override for it must be deleted. Its old +`strict`/`scope`/`additionalOfficialTools` values are validated only by that module and grant no +hiding authority here. -Only the selected OPS counterparts must be registered and visible as exact OPS-owned Native definitions. A foreign registration with the same name or identical schema cannot satisfy readiness. Ownership changes are checked even when the schema bytes stay unchanged; Native shadows created by the OPS PTC adapter retain their tracked ownership. PTC data APIs alone cannot replace a Native tool. An unavailable selected counterpart blocks the affected scope with `READINESS_BLOCKED` and lifts that scope's replacement masks; healthy scopes and inherited authority restrictions retain their masks. Removing a selection, unloading, or disabling the component restores the corresponding official tool. Old `strict`/`scope`/`additionalOfficialTools` values are validated but ignored until explicit `replaceTools` selections are supplied. +Hiding is per agent scope and only applies while the OPS counterpart is registered and visible as an +exact OPS-owned Native definition. A foreign registration with the same name or identical schema does +not qualify, and a missing counterpart leaves the official tool exposed instead of blocking prompt +assembly. Ownership changes are rechecked even when the schema bytes stay unchanged; Native shadows +created by the OPS PTC adapter retain their tracked ownership. Disabling a mode row or enabling +Compatibility mode restores the corresponding official tool. -The resident `ops_read_files` is not the reviewed replacement for `read`. Before selecting `read`, configure discovery `alwaysLoaded: [inspect_local_file]` or ensure that single-file capability is already active in that Native scope. Apply the same readiness rule to `glob` and `grep`. Internal presentation inspection lets discovery publish these configured tools during startup; ordinary prompt assembly and dispatch remain blocked until they are ready. +The resident `ops_read_files` is not the counterpart for `read`; Standard and Cordis publish +`ops_inspect_local_file`, `ops_glob` and `ops_grep` as the reviewed counterparts. -The component exports StandardSchemaV1 validation together with public JSON schema descriptors for built-in host row forms. Optional browser multiselect pages require the explicit client carrier migration; see [configuration](configuration-reference.md). +Mode rows export StandardSchemaV1 validation together with public JSON schema descriptors for the +built-in host row forms. Optional browser settings pages require the explicit client carrier +migration; see [configuration](configuration-reference.md). --- diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index d1ad9598..7a2d77e5 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -6,22 +6,22 @@ Every configuration key, and **which component row owns it**. Active rows expose validated settings through the host Settings/ConfigEditor. Accepted changes persist through the profile writer and reload the owning row; stale or invalid changes preserve saved values. Disabled rows must first be re-enabled with the host row switch, and pending services must become available before editing. -The default bundle retains `dsh-ops/file` and stable ID `dsh-ops-file`, so saved name-qualified overrides continue to match. It does not activate custom browser pages on the pinned host. +The default bundle declares the eight rows listed below and no legacy `shell`, `file`, `ptc` or `hide-official` row. A saved profile override for a removed row matches no target, is reported by the host patch composer and must be deleted. The default bundle does not activate custom browser pages on the pinned host. ## Optional browser settings pages -When a bare-package client carrier is configured, the replacement, discovery and shell rows have settings pages in the host plugin manager. The default seven-row bundle keeps its legacy file module name and does not activate these pages. Changes -remain a draft until **Save changes** succeeds. A stale or refused save keeps the draft for review; -**Reset to saved** loads the latest saved values. Accepted changes persist through the host profile -writer and reload the affected row. +When the client carrier bundle is selected, the four mode rows and the discovery row have settings +pages in the host plugin manager. The default bundle keeps its built-in row forms and does not +activate these pages. Changes remain a draft until **Save changes** succeeds. A stale or refused save +keeps the draft for review; **Reset to saved** loads the latest saved values. Accepted changes +persist through the host profile writer and reload the affected row. -- **Replacements:** choose `read`, `glob` and `grep` independently. Selected tools use OPS contracts. - Keep each selected counterpart (`inspect_local_file` for `read`, `glob` or `grep`) always loaded in Discovery, or activate it before selecting the replacement. `read_files` does not satisfy the single-file `read` replacement contract. -- **Discovery:** choose the allowed pool, its always-loaded subset and a result limit from 1 to 5. - Removing an allowed capability removes it from the always-loaded subset in the same save. -- **Shell:** enable Bash, enter an absolute executor override, or select detected managed/Git Bash. +- **Mode rows:** enable Bash publication and enter an absolute executor override, or select detected + managed/Git Bash. The PTC mode row additionally exposes its own pool through the shared schema. Selecting a runtime fills the draft path; save it explicitly. Clearing the override inherits the configured default. Runtime installation does not silently change that selection. +- **Discovery:** choose the allowed pool, its always-loaded subset and a result limit from 1 to 5. + Removing an allowed capability removes it from the always-loaded subset in the same save. Bash installation/removal and PowerShell upgrade buttons first show a plan. Execute that reviewed plan explicitly; cancellation after launch can leave changes and reports an unknown effect. Managed @@ -29,11 +29,13 @@ Bash removal only deletes receipt-owned installations. Stop Bash jobs first. Pow WinGet for the standard host PowerShell 7 installation, preserve Windows PowerShell 5.1, and require a DSH restart. Check a custom host `pwshPath` separately. No installer runs when opening a page. -A client carrier uses the stable `dsh-ops-file` row with the bare package name `dsh-ops` to load these browser pages. Its -server behavior remains the file component; the `dsh-ops/file` export remains compatible. Disabling -this carrier unloads all custom settings pages. Use the host's **built-in row switch** to re-enable -it. There is no automatic form fallback. A disabled target row must also be re-enabled before its -settings can be edited. The discovery page is available once its component row is installed. +A client carrier is the nested `dsh-ops/lib/profiles/client` bundle selected instead of the default +`dsh-ops` bundle. The nested bundle is generated from the canonical bundle and renames only the +standard-mode row to the package root `dsh-ops` so the client half can load these browser pages; +the other seven rows are unchanged and the row still applies Standard mode. Removing the carrier +bundle selection unloads all custom settings pages, and the default bundle keeps the built-in forms. +There is no automatic form fallback. A disabled target row must also be re-enabled before its +settings can be edited. ### Enable the optional browser carrier @@ -46,10 +48,12 @@ dsh-ops settings-profile --profile-dir --host-anchor `, which reports `restricted: yes/no` for the resolved bash. See [Troubleshooting](troubleshooting.md#ops_bash-cannot-run-a-custom-bashpath-or-git-for-windows-in-restricted-modes). -- `deniedHostTools`: the host tool names refused by the `deny-host-shell` policy. -- `configUpdateVersion`: completion marker for the configuration migrations (currently `2`: update 1 retires the old implicit system-bash opt-in, update 2 removes the deprecated FastCtx keys). Do not remove it, or the self-check runs again. +- `configUpdateVersion`: completion marker for the configuration migrations (currently `2`: update 1 retires the old implicit system-bash opt-in, update 2 removes the deprecated FastCtx keys). It is accepted by every mode row; do not remove it, or the self-check runs again. Disabling the row or saving `capture: false` stops event collection immediately, cancels queued filesystem work, and waits for already-started I/O before the host finishes the change. It does not start a final fold: existing NDJSON remains recoverable, and a temporary file from an interrupted fold may remain. Re-enabling captures only subsequent events; disabled events are not replayed. - The panel row defaults disabled and registers no listener or file. Once enabled, `capture` defaults to `true`; an explicit legacy `false` still registers nothing and is never silently changed. The host cannot atomically clear that old field while disabling its row, so the compatibility setting remains visible for deliberate edits. Captured events write into the session working directory: @@ -182,36 +193,44 @@ Disabling the row or saving `capture: false` stops event collection immediately, --- -## Per-tool official replacements (`dsh-ops/hide-official`) - -The row is **disabled by default** and `replaceTools` defaults to `[]`. Enabling an untouched row hides nothing. Choose any subset of the reviewed official names `read`, `glob`, and `grep`; unselected tools stay visible without warnings. These are deliberate alternative workflows: read the [mapping and coverage differences](component-reference.md#dsh-opshide-official--per-tool-replacements) before selecting them. - -| Key | Type | Default | Meaning | -| --- | --- | --- | --- | -| `replaceTools` | string[] | `[]` | Official tools to replace. Only `read`, `glob`, `grep` are accepted; duplicates are removed. Unknown keys and unsupported names produce errors naming the key/value. | - -For example, replace only host read and grep while retaining host glob: - -```yaml -- name: dsh-ops/hide-official - config: - replaceTools: [read, grep] -``` - -Set `replaceTools: []` or disable the row to restore every official tool. A selected OPS counterpart must be an exact OPS-owned Native definition registered and visible in each agent scope; a same-name foreign tool or a PTC-only data API does not qualify. If any is missing, that scope restores its masks and refuses prompt assembly and execution with `READINESS_BLOCKED`; healthy scopes remain usable. Restore the counterpart, remove that selection, or disable the row. Unrelated OPS tools are not required. +## Compatibility mode (`dsh-ops/compat`) -With the slim Native defaults, configure discovery `alwaysLoaded: [inspect_local_file, grep]` before selecting the example above, or activate those Native capabilities first. `read` still maps to `inspect_local_file`; resident `read_files` does not satisfy that selection. Removing or shadowing an owned counterpart rechecks readiness immediately, even if its schema is byte-identical. +The row is **disabled by default** and accepts no configuration. Enabling it sets one runtime switch: +every enabled mode row keeps publishing its OPS tools, but no official tool is hidden — `read`, +`glob`, `grep` and the Windows official shell stay exposed and callable. Disabling it restores the +hiding each enabled mode row performs, without unloading any row. -**Migration from 0.3:** `strict`, `scope`, and `additionalOfficialTools` are accepted only for compatibility and validated with their old types/values. They grant no hiding authority, including `scope: extended` and aliases. An existing row containing only those keys becomes inert. Explicit `replaceTools` is the sole source of selections even if legacy keys remain. No saved profile is rewritten by this component. +- Hiding is per agent scope and applies only while the OPS counterpart of an official tool is + visible in that same scope. A missing counterpart leaves the official tool exposed instead of + removing a capability. +- The switch is a configuration fact; it does not change which tools a mode row publishes, and the + OPS counterparts remain callable while it is on. +- Compatibility costs token efficiency and model ability because both tool surfaces are visible. + Prefer enabling only the mode rows a profile needs over leaving Compatibility on. +- There is no per-tool `replaceTools` selection in this version. The removed + `dsh-ops/hide-official` row is a legacy module outside the bundle; delete a saved override for it. + Its old `strict`, `scope` and `additionalOfficialTools` keys are validated only by that legacy + module and grant no authority here. -Cordis validates configuration through `StandardSchemaV1`; detached public settings descriptors expose editable arrays in the built-in Settings/ConfigEditor. Accepted changes persist and reload the row. The optional browser multi-select uses the client carrier described above; the default legacy row does not activate it. +The built-in Compatibility form is the host row switch; the row has no editable settings page. +Cordis validates mode configuration through `StandardSchemaV1`, and the optional browser carrier +described above edits the mode and discovery rows. --- ## Prompt registration -Prompts are registered as scoped runtime sections, not written to AGENTS.md. Disabling shell withdraws its routing; disabling file withdraws its Native file guidance. Fresh Native file publishes only ops_read_files, discovery publishes ops_discover_tools, and shell scopes on every platform add ops_bash (the pinned Windows payload or the distribution Bash). Advanced Native tools are discovered or explicitly always loaded. PTC owns its separate typed SDK and current permission checks. The approved BM-03 budget measures file/discovery schemas at ≤400 tokens. Bash startup residency is unchanged; its schema and the complete startup total are reported separately, and missing Bash cannot satisfy the Windows readiness check. +Prompts are registered as scoped runtime sections, not written to AGENTS.md. Each enabled mode row +publishes routing guidance only for sessions composed from its preset, and only while its OPS tools +are visible in that scope; disabling the row withdraws it. The discovery row publishes guidance for +the capabilities it publishes, and Compatibility mode publishes none. Standard and Cordis publish +the resident OPS file tools, the discovery row serves every native session (Minimal included) while +it is enabled, and PTC owns its separate typed SDK and current permission checks. Advanced Native +tools are discovered or explicitly always loaded, and a capability a running mode already publishes +resident is not offered as a discovery choice. The approved BM-03 budget measures file/discovery schemas at ≤400 tokens. +Bash startup residency is unchanged; its schema and the complete startup total are reported +separately, and missing Bash cannot satisfy the Windows readiness check. --- @@ -223,17 +242,23 @@ Prompts are registered as scoped runtime sections, not written to AGENTS.md. Dis ## Host settings forms -All seven enabled rows expose their supported validated fields to the host settings service. Saving uses the profile revision the form read; a stale revision or invalid +Each enabled row exposes its supported validated fields to the host settings service. Saving uses the +profile revision the form read; a stale revision or invalid selection is rejected before writing. The host persists accepted changes and reloads the affected row, so the running configuration follows the saved selection. Ordinary row fields are preserved. A disabled row has no editable form; re-enable it with the host's built-in row switch first. -The shell descriptor includes routing policy, prompt guidance and system-shell fallback alongside Bash selection. Its internal revision marker, file-only and retired FastCtx compatibility keys are deliberately excluded. Discovery exposes all seven accepted fields, including `binaryPath`, `required` and `denyCredentialReads`. The release gate checks component entries, targets, published tool declarations, retained host commands and explicit bundle defaults against their runtime definitions. +A mode descriptor exposes Bash publication and an absolute executor override; PTC mode additionally +exposes its own pool through the shared schema. Internal revision markers, the removed file/shell +row keys and retired FastCtx compatibility keys are deliberately excluded. Discovery exposes all +seven accepted fields, including `binaryPath`, `required` and `denyCredentialReads`. The release +gate checks component entries, targets, published tool declarations, retained host commands and +explicit bundle defaults against their runtime definitions. Runtime actions use the host's authenticated gateway and managed subprocess service. Installation, removal and upgrade require a preview followed by execution of that current preview; expired, changed or reused plans are refused. Only one action runs at a time. Closing/unloading the owning -shell component cancels outstanding work and waits for settlement; cancellation after launch may +mode row cancels outstanding work and waits for settlement; cancellation after launch may leave changes and reports an unknown effect. Bash install refuses a store containing unowned version directories. The PowerShell action targets the standard WinGet installation and requires a DSH restart; an explicit host `pwshPath` remains authoritative. Installer output stays in host diff --git a/docs/discovery.md b/docs/discovery.md index 347b3e82..5c3ed7f1 100644 --- a/docs/discovery.md +++ b/docs/discovery.md @@ -4,7 +4,7 @@ `dsh-ops/discovery` publishes `ops_discover_tools` in each eligible Native scope. Describe a capability (`metadata revision hash`) or select an exact public name (`select:ops_stat`). Discovery only chooses tools; it performs no file action. A successful selection remains pending through the current tool round. Its owning session's committed `step/end` publishes it for the next provider request. The pinned host assembles before `agent/pre-step`; queued publication is therefore drained and the public prompt service reassembles a stale snapshot. Diagnostic assemblies before that committed boundary cannot activate pending names. Newly published tools remain scoped to their owner; parents, children and siblings must discover independently. -The seven-row bundle enables this component by default and preserves existing named row overrides. Its configuration is: +The eight-row bundle enables this component by default and preserves existing named row overrides. Its configuration is: ```yaml - name: dsh-ops/discovery diff --git a/docs/host-integration.md b/docs/host-integration.md index 4ddb6828..f69bdda8 100644 --- a/docs/host-integration.md +++ b/docs/host-integration.md @@ -16,25 +16,26 @@ The fact that the same whitelist hides a globally registered file tool does not prove it also hides the session's shell. A read-only sandbox is also a filesystem permission mode, not a promise that every command tool is absent. -To stop OPS Bash publication, set `publishBashTool: false` in its owning shell +To stop OPS Bash publication, set `publishBashTool: false` in the owning mode row. Merge this setting into the existing config, preserving other keys: ```yaml -- id: dsh-ops-shell - name: dsh-ops/shell +- id: dsh-ops-mode-standard + name: dsh-ops/mode-standard config: publishBashTool: false ``` -Check every `dsh-ops/shell` row in the effective configuration: another enabled -row can still publish its own tool. Apply the change through the host settings -or profile lifecycle, then verify a new session's tool list. This switch controls -OPS Bash; it does not disable independently installed command tools. +Check every enabled mode row in the effective configuration: another row can +still publish its own tool for sessions of its preset. Apply the change through +the host settings or profile lifecycle, then verify a new session's tool list. +This switch controls OPS Bash; it does not disable independently installed +command tools. Per-preset command denial requires a host execution policy that covers owned -tools, or a verified scoped mounting arrangement. Merely moving a shell row into -a preset is not established as an isolation guarantee: the component also -enumerates already live agents when mounted. Before relying on such an arrangement, +tools, or a verified scoped mounting arrangement. Merely enabling a mode +row is not established as an isolation guarantee: the row also enumerates +already live agents when mounted. Before relying on such an arrangement, verify both allowed and disallowed presets, existing and newly created sessions, and actual invocation rejection. OPS does not rewrite the shared tool registry to make inherited restrictions act on owned tools. diff --git a/docs/linux-build.md b/docs/linux-build.md index be48152e..d68f776f 100644 --- a/docs/linux-build.md +++ b/docs/linux-build.md @@ -12,9 +12,10 @@ packages to fetch. | Component | Linux x64 | Notes | | --- | --- | --- | -| `dsh-ops/file` | yes | 4 tools: `ops_inspect_local_file`, `ops_grep`, `ops_glob`, `ops_replace` | -| `dsh-ops/ptc` | yes | `ops_batch`, `ops_result`; opt-in | -| `dsh-ops/shell` | yes | `ops_bash` over the host bash; pwsh stays Windows x64 | +| `dsh-ops/mode-standard` | yes | resident OPS file tools and `ops_bash` over the host bash; no official tool is replaced on Linux | +| `dsh-ops/mode-cordis` | yes | same surface as Standard, with its own routing section | +| `dsh-ops/mode-minimal` | yes | dynamic-loading entry and `ops_bash` | +| `dsh-ops/mode-ptc` | yes | PTC SDK: `ops_batch`, `ops_result` and the typed catalogue | ## Prerequisites @@ -189,16 +190,16 @@ $ dsh --profile --dump-config ## Configuring the shell lane -`ops_bash` works on Linux out of the box: the `shell` component resolves the +`ops_bash` works on Linux out of the box: every enabled mode row resolves the distribution's own bash (`config.bashPath` → `PATH` → `/bin/bash`, `/usr/bin/bash`, `/usr/local/bin/bash`) instead of shipping one, and a confined mode must be confined by a host sandbox — if none can confine the call it is refused, never run unrestricted. -To use a different bash, name it explicitly on the shell row: +To use a different bash, name it explicitly on a mode row: ```yaml -- id: dsh-ops-shell +- id: dsh-ops-mode-standard config: bashPath: /usr/bin/bash ``` @@ -214,7 +215,7 @@ To use a different bash, name it explicitly on the shell row: | `Unsupported runtime target linux-x64` | a build whose `PLATFORMS` list predates Linux support | use a version that lists `linux-x64`, or build from a checkout that does | | `Error: Unsupported target` from `build:ptc`, after a successful compile | the stage step rejected the target | same as above; the compile itself is fine | | `npm error code EBADPLATFORM` | the published tarball declares `os: ["win32"]` | install from a checkout/`npm pack` through `dsh plugin` | -| file tools silently missing, no error | `resolveWorker()` threw and the component degraded: `required` defaults to `false` | set `required: true` on the `dsh-ops/file` row to make it loud | +| file tools silently missing, no error | `resolveWorker()` threw and the component degraded: `required` defaults to `false` | set `required: true` on the `dsh-ops/mode-standard` row to make it loud | | `Bad file descriptor (os error 9)` in `preflight` | the audit store or atomic replace synced a directory handle that cap-std opened `O_PATH` on Linux, where `fsync` is refused | fixed in `native/src/lib.rs`; rebuild with `npm run build:ptc` | | a denied command reports a generic failure instead of `POLICY_DENIED` | the host's English denial signatures were matched against a localized libc message | the bash lane pins `LC_MESSAGES=C`; a non-English host on an older build shows this | diff --git "a/docs/linux\346\236\204\345\273\272.md" "b/docs/linux\346\236\204\345\273\272.md" index f83118d1..addd9276 100644 --- "a/docs/linux\346\236\204\345\273\272.md" +++ "b/docs/linux\346\236\204\345\273\272.md" @@ -10,9 +10,10 @@ Linux 上**没有任何东西随安装包下载**:原生 worker 一律由本 | 组件 | Linux x64 | 说明 | | --- | --- | --- | -| `dsh-ops/file` | 支持 | 4 个工具:`ops_inspect_local_file`、`ops_grep`、`ops_glob`、`ops_replace` | -| `dsh-ops/ptc` | 支持 | `ops_batch`、`ops_result`;须显式启用 | -| `dsh-ops/shell` | 支持 | `ops_bash` 使用宿主 bash;pwsh 仍仅限 Windows x64 | +| `dsh-ops/mode-standard` | 支持 | 常驻 OPS 文件工具,`ops_bash` 使用宿主 bash;Linux 上不替换官方工具 | +| `dsh-ops/mode-cordis` | 支持 | 与 Standard 相同的工具面,使用自己的路由段 | +| `dsh-ops/mode-minimal` | 支持 | 动态加载入口与 `ops_bash` | +| `dsh-ops/mode-ptc` | 支持 | PTC SDK:`ops_batch`、`ops_result` 及类型化目录 | ## 前置条件 @@ -176,10 +177,10 @@ Linux 上 `ops_bash` 开箱可用:`shell` 组件不随包提供 shell,而是 bash(`config.bashPath` → `PATH` → `/bin/bash`、`/usr/bin/bash`、`/usr/local/bin/bash`); 受限模式必须由宿主沙箱落实约束,沙箱无法约束时拒绝执行,绝不无约束运行。 -要使用另一个 bash,在 shell 行显式指定: +要使用另一个 bash,在模式行显式指定: ```yaml -- id: dsh-ops-shell +- id: dsh-ops-mode-standard config: bashPath: /usr/bin/bash ``` @@ -194,7 +195,7 @@ bash(`config.bashPath` → `PATH` → `/bin/bash`、`/usr/bin/bash`、`/usr/lo | `Unsupported runtime target linux-x64` | 该构建的 `PLATFORMS` 列表早于 Linux 支持 | 换用列出 `linux-x64` 的版本,或用已支持的检出构建 | | 编译成功后 `build:ptc` 报 `Error: Unsupported target` | stage 步骤拒绝了该 target | 同上;编译本身没有问题 | | `npm error code EBADPLATFORM` | 已发布的 tarball 声明 `os: ["win32"]` | 从检出目录(或 `npm pack`)经 `dsh plugin` 安装 | -| file 工具无声消失、没有任何报错 | `resolveWorker()` 抛错后组件降级,而 `required` 缺省为 `false` | 给 `dsh-ops/file` 行设 `required: true`,让它变响 | +| file 工具无声消失、没有任何报错 | `resolveWorker()` 抛错后组件降级,而 `required` 缺省为 `false` | 给 `dsh-ops/mode-standard` 行设 `required: true`,让它变响 | | `preflight` 阶段报 `Bad file descriptor (os error 9)` | 审计库或原子替换对 cap-std 在 Linux 上以 `O_PATH` 打开的目录句柄执行 `fsync`,内核拒绝 | 已在 `native/src/lib.rs` 修复;用 `npm run build:ptc` 重建 | | 被拒命令报成普通失败而非 `POLICY_DENIED` | 宿主提供的英文拒绝签名去匹配被本地化的 libc 消息 | bash 通道已固定 `LC_MESSAGES=C`;旧构建在非英文宿主上会命中此问题 | diff --git a/docs/macos.md b/docs/macos.md index 9b1a6f38..187e94cc 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -53,8 +53,8 @@ by default and resolves an explicit `bashPath`, then PATH and Homebrew/system lo - System Bash: `/bin/bash` ```yaml -- id: dsh-ops-shell - name: dsh-ops/shell +- id: dsh-ops-mode-standard + name: dsh-ops/mode-standard config: bashPath: /opt/homebrew/bin/bash # Intel Homebrew: /usr/local/bin/bash ``` diff --git a/docs/macos.zh.md b/docs/macos.zh.md index 55306faa..b4863a43 100644 --- a/docs/macos.zh.md +++ b/docs/macos.zh.md @@ -48,8 +48,8 @@ postinstall 下载器。macOS 的 `ops_bash` 默认开启,按显式 `bashPath` - 系统 Bash:`/bin/bash` ```yaml -- id: dsh-ops-shell - name: dsh-ops/shell +- id: dsh-ops-mode-standard + name: dsh-ops/mode-standard config: bashPath: /opt/homebrew/bin/bash # Intel Homebrew: /usr/local/bin/bash ``` diff --git a/docs/native-ptc.md b/docs/native-ptc.md index b2039726..84fe8144 100644 --- a/docs/native-ptc.md +++ b/docs/native-ptc.md @@ -53,8 +53,8 @@ Rust >=1.88 is required. Build uses `cargo build --release --locked` against `native/Cargo.toml`. No install scripts download binaries. On Windows, enable the session toolchain before building; do not add multi-version toolchains to global PATH. -The `dsh-ops/ptc` bundle row is enabled by default after #170; existing explicit -profile disablement is preserved. Config accepts `required` (default false), an optional +The `dsh-ops/mode-ptc` row is enabled by default; existing explicit profile +disablement is preserved. Config accepts `required` (default false), an optional absolute `binaryPath`, `allowedTools`, and `alwaysLoaded`; see [SDK loading](ptc-sdk-loading.md). `DSH_OPS_WORKER_BIN` can select a build; otherwise the component uses `runtime/-/dsh-ops-worker[.exe]` before `native/target/release`. Published 0.3.1 packages include the runtime bytes diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 2fe98632..f576ae76 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -98,7 +98,7 @@ Symptom, in a read-only or workspace-write session: `EXECUTOR_UNAVAILABLE: ops_b What to do: -- Remove `bashPath` (and `allowSystemShellFallback`) from the `dsh-ops/shell` row so the bundled pinned bash is used; if that payload is missing, run `npx --yes dsh-ops@latest provision-shells --bash`. +- Remove `bashPath` (and `allowSystemShellFallback`) from the mode row so the bundled pinned bash is used; if that payload is missing, run `npx --yes dsh-ops@latest provision-shells --bash`. - Or switch the session to `danger-full-access`, where `bashPath` runs directly. - Check with the profile's own copy: `node "\node_modules\dsh-ops\bin\dsh-ops.mjs" ladder --config ` reports `restricted: yes/no` for the resolved bash. @@ -226,7 +226,7 @@ No. It is FastCtx's reusable shared process, started by earlier versions with th macOS ships no `@dsh-ops/bash-darwin-*` or `@dsh-ops/pwsh-darwin-*` package, and no portable upstream bash exists for macOS. Select the shell the plugin should use with `bashPath` (for example `/opt/homebrew/bin/bash` on Apple -Silicon, `/usr/local/bin/bash` on Intel, or `/bin/bash`) on the `dsh-ops/shell` +Silicon, `/usr/local/bin/bash` on Intel, or `/bin/bash`) on a mode row. PowerShell 7 can be provisioned, but the host disables its `pwsh-sandbox` and `tool-pwsh` rows outside Windows, so no pwsh tool appears on macOS. diff --git a/docs/upgrading.md b/docs/upgrading.md index d1c89beb..d759c523 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -72,6 +72,15 @@ removed the dependency or payload. Verify the final profile manifest, bundle selection and installed package state through the official manager. Transaction rollback and registry persistence belong to DSH's package manager. +## Moving from 0.4.x to 0.5.x + +| Change | Action after upgrade | +| --- | --- | +| The bundle now declares eight rows: four preset-mode rows (`mode-minimal`, `mode-standard`, `mode-ptc`, `mode-cordis`), `discovery`, `compat`, `panel` and `tui`. The `shell`, `file`, `ptc` and `hide-official` rows are gone. | Delete saved profile overrides for the removed rows; a leftover override has no target and produces a startup `patch: entry ... not found` warning. Re-set the mode row switches you want. | +| An enabled mode row hides the official tools it replaces: `read`/`glob`/`grep` in Standard and Cordis, and the Windows official shell in every mode. | There is no per-tool selection any more. To keep official tools visible, enable the `dsh-ops/compat` switch; disable a mode row to stop publishing its OPS surface. | +| Windows command execution now uses `ops_bash` instead of the official `pwsh` while a mode row is enabled and the OPS counterpart is visible. | Use `ops_bash` for commands and host `pwsh` only for Windows-native functionality; enable Compatibility mode if a workflow needs the official shell. | +| Data inspection, the TUI sidebar and Compatibility mode remain opt-in. | Enable their rows when needed; they start disabled in a fresh profile. | + ## Moving from 0.3.x to 0.4.x | Change | Action after upgrade | diff --git a/docs/usage.md b/docs/usage.md index 0eb4a3f5..9a9ba1e4 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -3,18 +3,28 @@ The complete install, update and remove flow, plus every option of the command-line interface. Before replacing a running installation, read [Upgrade and uninstall safely](upgrading.md) -for stopping the host, registry-policy failures and the 0.3.x-to-0.4.x migration checklist. - -## 0.4.0 candidate and component defaults - -This checkout is an unreleased candidate; acceptance uses the approved OPS 0.3.x BM-01 comparator and file/discovery BM-03 budget, with Bash still resident and the full total reported separately. -See [candidate status](release-0.4.0.md). The seven rows enable file/PTC/discovery and shell on all -the supported platforms, and leave panel/TUI/replacements off in fresh profiles. Existing explicit -switches and name-qualified settings survive upgrade. Only read_files/discover_tools are initially resident -in Native, plus the available Bash (the pinned payload on Windows, the distribution Bash on POSIX); -documents load on demand. The PTC catalogue has 18 typed APIs, with its -own configured pool and SDK loader. [Configuration](configuration-reference.md) describes built-in -forms and the optional browser carrier. Use [manual acceptance](manual-validation.md) for upgrades. +for stopping the host, registry-policy failures and the 0.4.x-to-0.5.x migration checklist. + +## 0.5.0 candidate and component defaults + +This checkout is an unreleased candidate. The bundle declares eight component rows: four enhanced +built-in preset modes (`dsh-ops/mode-minimal`, `dsh-ops/mode-standard`, `dsh-ops/mode-ptc`, +`dsh-ops/mode-cordis`), dynamic loading (`dsh-ops/discovery`), Compatibility mode +(`dsh-ops/compat`), data inspection (`dsh-ops/panel`) and TUI integration (`dsh-ops/tui`). +Fresh profiles enable the four mode rows and discovery; Compatibility, panel and TUI stay off. +Existing explicit switches survive upgrade, but a saved override for a removed `shell`, `file`, +`ptc` or `hide-official` row must be deleted; it matches no row in the new bundle. + +A mode row publishes OPS tools only for sessions composed from its preset and hides the official +tools it replaces: `read`, `glob` and `grep` in Standard and Cordis, and the Windows official +shell (`pwsh`/`pwsh_persistent` → `ops_bash`) in every enabled mode. Hiding only happens while +the OPS counterpart is visible in the same scope. Compatibility mode keeps the official tools +exposed without unloading a row. Standard and Cordis publish the resident OPS file tools, Minimal +sessions receive the dynamic-loading entry while the discovery row is enabled, and PTC mode +publishes its 18-API typed catalogue with its own configured pool and SDK loader; documents load on +demand. +[Configuration](configuration-reference.md) describes the built-in forms and the optional browser +carrier. Use the [upgrade notes](upgrading.md#moving-from-04x-to-05x) for migration details. --- @@ -44,11 +54,11 @@ node bin/native-runtime.mjs stage --platform darwin-arm64 node bin/native-runtime.mjs stage --platform darwin-x64 ``` -Then point the shell row at a bash the machine already has: +Then point a mode row at a bash the machine already has: ```yaml -- id: dsh-ops-shell - name: dsh-ops/shell +- id: dsh-ops-mode-standard + name: dsh-ops/mode-standard disabled: false config: bashPath: /opt/homebrew/bin/bash # Intel Homebrew: /usr/local/bin/bash @@ -85,7 +95,7 @@ dsh-ops bash-runtime remove # preview only dsh-ops bash-runtime remove --yes # remove receipt-marked managed versions ``` -`git` and `managed` print an absolute `bashPath` configuration for the host config editor; they do not edit a profile or PATH. Reload the shell component after applying the selection or installing/removing its runtime. Stop running Bash jobs before removal. Removal refuses links in the managed path and preserves unmarked versions, system Git and PowerShell. `install` reuses the pinned download and checksum checks from `provision-shells --bash`; `--dry-run` makes no download or change. Windows restricted modes still require the pinned MSYS IPC compatibility match. A different Git Bash remains a full-access choice; approval never repairs an incompatible restricted runtime. Linux/macOS continue using host Bash. +`git` and `managed` print an absolute `bashPath` configuration for the host config editor; they do not edit a profile or PATH. Reload the mode row after applying the selection or installing/removing its runtime. Stop running Bash jobs before removal. Removal refuses links in the managed path and preserves unmarked versions, system Git and PowerShell. `install` reuses the pinned download and checksum checks from `provision-shells --bash`; `--dry-run` makes no download or change. Windows restricted modes still require the pinned MSYS IPC compatibility match. A different Git Bash remains a full-access choice; approval never repairs an incompatible restricted runtime. Linux/macOS continue using host Bash. ### One-command npm installation @@ -134,10 +144,11 @@ dsh --profile ops-test --dump-config-schema ``` Use the intended package version or a packed checkout instead of `@latest` when -testing an unpublished change. The dump must contain the seven `dsh-ops-*` rows -(`shell`, `file`, `ptc`, `discovery`, `panel`, `tui`, `hide-official`) with no -`patch: entry ... not found` diagnostic. Saved profile overrides remain effective; -new opt-in rows remain disabled until enabled. CI's manifest validation runs the +testing an unpublished change. The dump must contain the eight `dsh-ops-*` rows +(`mode-minimal`, `mode-standard`, `mode-ptc`, `mode-cordis`, `discovery`, `compat`, +`panel`, `tui`) with no `patch: entry ... not found` diagnostic. Saved profile overrides on +those rows remain effective; an override for a removed `shell`, `file`, `ptc` or +`hide-official` row must be deleted. New opt-in rows remain disabled until enabled. CI's manifest validation runs the same public host patch composer and rejects skipped overrides, alongside each component's config validator. A regression removes an override target and verifies the resulting warning fails the gate. @@ -253,7 +264,7 @@ The host job list and waiting view use `description` as the task label. Both `ba ## Process reclamation -This version starts no long-lived server process: the native file worker is owned by the file component and stops with it. Earlier versions with the background component started `fastctx serve` clients and could leave a shared FastCtx `runtime-host`; that process exits on its own after about ten minutes without clients. This release does not sweep processes left by older versions; do not kill them by process-name sweeps. +This version starts no long-lived server process: the native file worker is owned by the enabled mode rows (and the discovery row) and stops with them. Earlier versions with the background component started `fastctx serve` clients and could leave a shared FastCtx `runtime-host`; that process exits on its own after about ten minutes without clients. This release does not sweep processes left by older versions; do not kill them by process-name sweeps. --- diff --git "a/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" index 205f01d1..34e44898 100644 --- "a/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -3,11 +3,13 @@ 安装、更新、卸载的完整流程,以及命令行接口的全部选项。 替换运行中的安装前,先阅读[安全升级与卸载](升级与卸载.md):停止宿主、registry -策略失败处理,以及从 0.3.x 到 0.4.x 的迁移清单。 +策略失败处理,以及从 0.4.x 到 0.5.x 的迁移清单。 -## 0.4.0 候选与组件默认值 +## 0.5.0 候选与组件默认值 -本检出是未发布候选;验收采用已批准的 OPS 0.3.x BM-01 基线与文件/发现 BM-03 预算,Bash 仍常驻且完整总数另列,见[候选状态](0.4.0发布候选.md)。新 profile 的七行默认开启 file/PTC/discovery,三个平台都开启 shell,panel/TUI/替换关闭;已有显式开关和带名称配置在升级后保留。Native 初始常驻 read_files/discover_tools 及可用 Bash(Windows 为固定载荷,POSIX 为发行版 bash),文档能力按需加载。PTC 有18个类型化 API,使用独立配置池与 SDK 加载器。[配置参考](配置参考.md)说明内置表单和可选浏览器承载行;升级按[手动验收](手动验收.md)复核。 +本检出是未发布候选。Bundle 声明八行组件:四个增强的内置预设模式(`dsh-ops/mode-minimal`、`dsh-ops/mode-standard`、`dsh-ops/mode-ptc`、`dsh-ops/mode-cordis`)、动态加载(`dsh-ops/discovery`)、兼容模式(`dsh-ops/compat`)、数据检测(`dsh-ops/panel`)与 TUI 集成(`dsh-ops/tui`)。新 profile 默认开启四个模式行与 discovery,兼容模式、panel 与 TUI 默认关闭。已有显式开关在升级后保留,但已移除的 `shell`、`file`、`ptc`、`hide-official` 行的已保存覆盖必须删除,它们在新 bundle 中没有对应行。 + +模式行只为来自对应预设的会话发布 OPS 工具,并隐藏被替换的官方工具:Standard 与 Cordis 替换官方 `read`、`glob`、`grep`,所有启用模式在 Windows 上替换官方 shell(`pwsh`/`pwsh_persistent` → `ops_bash`)。只有同一作用域内 OPS 对应工具可见时才会隐藏。兼容模式保持官方工具可见,且不卸载任何行。Standard/Cordis 发布常驻 OPS 文件工具,discovery 行启用时 Minimal 会话获得动态加载入口,PTC 模式发布 18 个类型化 API 的目录及其独立配置池与 SDK 加载器;文档能力按需加载。[配置参考](配置参考.md)说明内置表单和可选浏览器承载行;迁移见[升级说明](升级与卸载.md#从-04x-迁移到-05x)。 --- @@ -36,11 +38,11 @@ node bin/native-runtime.mjs stage --platform darwin-arm64 node bin/native-runtime.mjs stage --platform darwin-x64 ``` -然后在 shell 组件行用 `bashPath` 指向本机已有的 bash: +然后在模式行用 `bashPath` 指向本机已有的 bash: ```yaml -- id: dsh-ops-shell - name: dsh-ops/shell +- id: dsh-ops-mode-standard + name: dsh-ops/mode-standard disabled: false config: bashPath: /opt/homebrew/bin/bash # Intel Homebrew: /usr/local/bin/bash @@ -74,7 +76,7 @@ dsh-ops bash-runtime remove # 只预览 dsh-ops bash-runtime remove --yes # 只移除有托管收据的版本 ``` -`git` 和 `managed` 输出供宿主配置编辑器使用的绝对 `bashPath`,不改 profile 或 PATH。应用选择、安装或移除运行时后重新加载 shell 组件。移除前先停止 Bash jobs;移除会拒绝托管路径中的链接,并保留没有收据的目录、系统 Git 与 PowerShell。`install` 复用 `provision-shells --bash` 的固定下载与校验流程;`--dry-run` 不下载、不写入。Windows 受限模式仍要求固定 MSYS 与 IPC 兼容性匹配;其它 Git Bash 只能用于完全访问模式,审批无法修复不兼容的受限运行时。Linux/macOS 继续使用宿主 Bash。 +`git` 和 `managed` 输出供宿主配置编辑器使用的绝对 `bashPath`,不改 profile 或 PATH。应用选择、安装或移除运行时后重新加载模式行。移除前先停止 Bash jobs;移除会拒绝托管路径中的链接,并保留没有收据的目录、系统 Git 与 PowerShell。`install` 复用 `provision-shells --bash` 的固定下载与校验流程;`--dry-run` 不下载、不写入。Windows 受限模式仍要求固定 MSYS 与 IPC 兼容性匹配;其它 Git Bash 只能用于完全访问模式,审批无法修复不兼容的受限运行时。Linux/macOS 继续使用宿主 Bash。 ### npm 一键安装 @@ -117,9 +119,10 @@ dsh --profile ops-test --dump-config dsh --profile ops-test --dump-config-schema ``` -验证未发布改动时,将 `@latest` 换成目标版本或本地打包产物。配置中应有七个 `dsh-ops-*` -行(`shell`、`file`、`ptc`、`discovery`、`panel`、`tui`、`hide-official`),且不出现 -`patch: entry ... not found`。已保存的 profile override 继续生效;新增的可选行默认关闭。 +验证未发布改动时,将 `@latest` 换成目标版本或本地打包产物。配置中应有八个 `dsh-ops-*` +行(`mode-minimal`、`mode-standard`、`mode-ptc`、`mode-cordis`、`discovery`、`compat`、 +`panel`、`tui`),且不出现 `patch: entry ... not found`。这些行上的已保存 override 继续生效; +已移除的 `shell`、`file`、`ptc`、`hide-official` 行覆盖必须删除。新增的可选行默认关闭。 CI 清单校验使用同一个公开宿主 patch 合成器,将跳过 override 的警告视为失败,并校验每个 组件配置。回归测试会删除一个 override 的目标行,确认门禁能捕获这类漂移。 @@ -226,7 +229,7 @@ dsh-ops panel --port 8123 # 固定回环端口;默认 0 由系统分配 ## 进程回收 -本版本不启动常驻服务进程:原生文件 worker 归 file 组件所有,随组件一同停止。带后台组件的早期版本会启动 `fastctx serve` 客户端,并可能留下共享的 FastCtx `runtime-host`;该进程在无客户端约十分钟后自行退出。新版不清理旧版本留下的进程,也不要按进程名批量杀它们。 +本版本不启动常驻服务进程:原生文件 worker 归已启用的模式行(与 discovery 行)所有,随这些行一同停止。带后台组件的早期版本会启动 `fastctx serve` 客户端,并可能留下共享的 FastCtx `runtime-host`;该进程在无客户端约十分钟后自行退出。新版不清理旧版本留下的进程,也不要按进程名批量杀它们。 --- diff --git "a/docs/\345\215\207\347\272\247\344\270\216\345\215\270\350\275\275.md" "b/docs/\345\215\207\347\272\247\344\270\216\345\215\270\350\275\275.md" index 2bc1fafb..25219eac 100644 --- "a/docs/\345\215\207\347\272\247\344\270\216\345\215\270\350\275\275.md" +++ "b/docs/\345\215\207\347\272\247\344\270\216\345\215\270\350\275\275.md" @@ -60,6 +60,15 @@ npm view @dsh-ops/bash-win32-x64@0.2.1 version dist.integrity --registry=https:/ 停用了 bundle,不代表依赖和载荷已移除;通过官方管理器核对最终清单、bundle 选择和安装状态。事务回滚与 registry 的持久化由 DSH 包管理器负责。 +## 从 0.4.x 迁移到 0.5.x + +| 变化 | 升级后的操作 | +| --- | --- | +| Bundle 现声明八行:四个预设模式行(`mode-minimal`、`mode-standard`、`mode-ptc`、`mode-cordis`)与 `discovery`、`compat`、`panel`、`tui`。`shell`、`file`、`ptc`、`hide-official` 行已移除。 | 删除为已移除行保存的 profile 覆盖;遗留覆盖没有目标,会产生启动日志 `patch: entry ... not found` 警告。按需重设模式行开关。 | +| 已启用模式行会隐藏其替换的官方工具:Standard 与 Cordis 的 `read`/`glob`/`grep`,以及所有模式的 Windows 官方 shell。 | 不再有逐工具选择。要保留官方工具可见,请启用 `dsh-ops/compat` 开关;关闭模式行可停止发布其 OPS 工具面。 | +| 模式行启用且 OPS 对应工具可见时,Windows 命令执行改用 `ops_bash` 而非官方 `pwsh`。 | 命令使用 `ops_bash`,仅 Windows 原生功能使用宿主 `pwsh`;若工作流需要官方 shell,请启用兼容模式。 | +| 数据检测、TUI 侧栏与兼容模式仍为可选项。 | 需要时再启用对应行;新 profile 中它们默认关闭。 | + ## 从 0.3.x 迁移到 0.4.x | 变化 | 升级后的操作 | diff --git "a/docs/\345\256\277\344\270\273\351\233\206\346\210\220.md" "b/docs/\345\256\277\344\270\273\351\233\206\346\210\220.md" index e49b4ea9..f3f7dd58 100644 --- "a/docs/\345\256\277\344\270\273\351\233\206\346\210\220.md" +++ "b/docs/\345\256\277\344\270\273\351\233\206\346\210\220.md" @@ -12,22 +12,22 @@ 继承工具白名单也会移除 `ops_bash`。同一白名单能隐藏全局注册的文件工具,并不能证明它 能隐藏会话自身的 shell。只读沙箱也是文件系统权限模式,不承诺命令工具全部不可见。 -要停止 OPS Bash 发布,在拥有它的 shell 行设置 `publishBashTool: false`。 +要停止 OPS Bash 发布,在拥有它的模式行设置 `publishBashTool: false`。 把该字段合并进已有配置,保留其他键: ```yaml -- id: dsh-ops-shell - name: dsh-ops/shell +- id: dsh-ops-mode-standard + name: dsh-ops/mode-standard config: publishBashTool: false ``` -检查有效配置中的所有 `dsh-ops/shell` 行:另一条启用的行仍可能发布自己的工具。 +检查有效配置中的所有已启用模式行:另一条行仍可能为它自己的预设发布工具。 通过宿主设置或 profile 生命周期应用变更,再验证新会话的工具清单。该开关只控制 OPS Bash,不会禁用其他独立安装的命令工具。 按 preset 禁止命令,需要能覆盖自有工具的宿主执行策略,或经过验证的作用域挂载方案。 -仅把 shell 行移入某个 preset,尚不能作为隔离保证:组件挂载时也会枚举已存活的 agent。 +仅启用某个模式行,尚不能作为隔离保证:该行挂载时也会枚举已存活的 agent。 依赖此方案前,应验证允许与禁止的 preset、已有与新建会话,以及实际调用被拒绝的结果。 OPS 不会改写共享工具注册表,让继承掩码强行作用于自有工具。 diff --git "a/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" "b/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" index 4c4d3c02..42ecf350 100644 --- "a/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" +++ "b/docs/\345\267\245\345\205\267\345\217\221\347\216\260.md" @@ -4,7 +4,7 @@ English: [Advanced tools on demand](discovery.md). `dsh-ops/discovery` 在每个符合条件的 Native 作用域中发布 `ops_discover_tools`。可描述所需能力(`metadata revision hash`),或指定完整公开名称(`select:ops_stat`)。发现仅选择工具,不执行文件操作。成功选择在当前工具轮次中保持待激活状态;所属会话提交 `step/end` 后,为紧接着的供应商请求发布。固定版本宿主先组装提示再触发 `agent/pre-step`,因此组装时等待本插件发布队列完成,并通过公开提示服务重新组装过期快照。提交边界前的诊断组装不会激活排队名称。注册只属于调用者;父、子和同级作用域都需独立发现。 -七行 bundle 默认启用该组件,并保留已有带名称行覆盖。其配置如下: +八行 bundle 默认启用该组件,并保留已有带名称行覆盖。其配置如下: ```yaml - name: dsh-ops/discovery diff --git "a/docs/\345\270\270\350\247\201\351\227\256\351\242\230.md" "b/docs/\345\270\270\350\247\201\351\227\256\351\242\230.md" index 837cad38..dc0eb96c 100644 --- "a/docs/\345\270\270\350\247\201\351\227\256\351\242\230.md" +++ "b/docs/\345\270\270\350\247\201\351\227\256\351\242\230.md" @@ -95,7 +95,7 @@ Bash 的空输入现在使用立即关闭的管道,而不是 Windows NUL 设 处理办法: -- 从 `dsh-ops/shell` 行删除 `bashPath`(以及 `allowSystemShellFallback`),改用随包的固定摘要 bash;若该载荷缺失,运行 `npx --yes dsh-ops@latest provision-shells --bash`。 +- 从模式行删除 `bashPath`(以及 `allowSystemShellFallback`),改用随包的固定摘要 bash;若该载荷缺失,运行 `npx --yes dsh-ops@latest provision-shells --bash`。 - 或者把会话切换到 `danger-full-access`,此时 `bashPath` 会直接运行。 - 用 profile 自己的副本检查:`node "\node_modules\dsh-ops\bin\dsh-ops.mjs" ladder --config <文件>`,报告中的 `restricted: yes/no` 表示受限模式能否运行所选 bash。 @@ -206,7 +206,7 @@ Bash 的空输入现在使用立即关闭的管道,而不是 Windows NUL 设 ### 没有自带的 bash / PowerShell 载荷 -macOS 没有 `@dsh-ops/bash-darwin-*` 或 `@dsh-ops/pwsh-darwin-*`,也没有可移植的官方 macOS bash。在 `dsh-ops/shell` 行用 `bashPath` 指定要使用的 bash(Apple Silicon 例如 `/opt/homebrew/bin/bash`,Intel 例如 `/usr/local/bin/bash`,或 `/bin/bash`)。PowerShell 7 可以 provision,但宿主在非 Windows 上禁用了 `pwsh-sandbox` 与 `tool-pwsh` 两行,所以 macOS 上不会出现 pwsh 工具。 +macOS 没有 `@dsh-ops/bash-darwin-*` 或 `@dsh-ops/pwsh-darwin-*`,也没有可移植的官方 macOS bash。在模式行用 `bashPath` 指定要使用的 bash(Apple Silicon 例如 `/opt/homebrew/bin/bash`,Intel 例如 `/usr/local/bin/bash`,或 `/bin/bash`)。PowerShell 7 可以 provision,但宿主在非 Windows 上禁用了 `pwsh-sandbox` 与 `tool-pwsh` 两行,所以 macOS 上不会出现 pwsh 工具。 ### macOS 12:replace/copy 返回 `METADATA_UNSUPPORTED` diff --git "a/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" "b/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" index e986793b..7f166e46 100644 --- "a/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" +++ "b/docs/\347\273\204\344\273\266\345\217\202\350\200\203.md" @@ -1,18 +1,21 @@ # 组件参考 -七个组件分别发布什么工具、参数是什么、需要什么权限。 +八个组件分别发布什么工具、参数是什么、需要什么权限。 --- ## 组件总览 | 配置行 `name` | 介绍 | 默认 | 发布的工具 | | --- | --- | --- | --- | -| `dsh-ops/shell` | 优先使用 bash,无法覆盖时使用宿主 PowerShell | 开(三个平台) | `ops_bash`;宿主 `pwsh` 保持其原执行器 | -| `dsh-ops/file` | 有界 Rust 文件检索与针对模型的文本输出 | 开 | `ops_read_files`;高级工具通过发现加载 | -| `dsh-ops/ptc` | PTC 会话的类型化文件 API 与渐进式 SDK | 开 | `ops_inspect_local_file`、`ops_result`、`ops_load_sdk`;高级 API 通过 SDK 加载 | +| `dsh-ops/mode-minimal` | 增强的 Minimal 预设:动态加载与 Windows 官方 shell 替换 | 开 | `ops_bash`;无常驻文件工具 | +| `dsh-ops/mode-standard` | 增强的 Standard 预设:常驻文件工具面、官方 read/glob/grep 与 Windows shell 替换 | 开 | `ops_read_files`、`ops_inspect_local_file`、`ops_glob`、`ops_grep`、`ops_result`、`ops_bash` | +| `dsh-ops/mode-ptc` | 增强的 PTC 预设:类型化 SDK 与 Windows shell 替换 | 开 | PTC SDK API(18 个 API 目录)、`ops_bash`;不做 Native 文件替换 | +| `dsh-ops/mode-cordis` | 增强的 Cordis 预设:常驻文件工具面、官方 read/glob/grep 与 Windows shell 替换 | 开 | 与 Standard 相同,使用自己的路由段 | | `dsh-ops/discovery` | 按需加载 Native 高级工具 | 开 | `ops_discover_tools` | +| `dsh-ops/compat` | 兼容模式:所有官方工具保持可见 | 关 | 无;已启用模式行的 OPS 工具继续发布 | | `dsh-ops/panel` | 数据采集,开关默认关:新对话写入 `/.dsh-ops/panel/.json` 与读取它的 `panel.html` | 行关;capture 默认 true | 无;见[配置参考](配置参考.md) | | `dsh-ops/tui` | 当前会话的只读 OPS 侧栏 | 关 | 无;见 [侧栏说明](tui-panel.zh.md) | -| `dsh-ops/hide-official` | 逐项选择 read/glob/grep 替代流程;未选官方工具保持可见 | 关 | 无 | + +`dsh-ops/discovery` 行拥有 `ops_discover_tools` 与所有模式的高级能力。模式行只为来自对应内置预设的会话发布工具,且只在 OPS 对应工具于同一作用域可见时隐藏被替换的官方工具:Standard 与 Cordis 替换官方 `read`、`glob`、`grep`,所有模式在 Windows 上替换官方 shell(`pwsh`/`pwsh_persistent` → `ops_bash`)。兼容模式改为保持官方工具可见。原 `dsh-ops/shell`、`dsh-ops/file`、`dsh-ops/ptc`、`dsh-ops/hide-official` 模块仅作为旧 profile 的 legacy 导出保留,不属于 bundle。 原 `dsh-ops/background` 组件(FastCtx 后台任务:`ops_run`、`ops_run_background`、`ops_job_output`、`ops_job_list`、`ops_job_kill`)已移除。仍写着它的 profile 行会作为空操作加载:接受任意配置、不发布任何工具,只记录一条警告提示可删除该行。 @@ -43,9 +46,12 @@ --- -## `dsh-ops/file` —— 文件工具 +## 预设模式 —— 文件工具 -仅有界多文件读取器 `ops_read_files` 常驻;含 hex 读取的 `ops_inspect_local_file` 按需发现。下述高级工具契约通过[工具发现](工具发现.md)或显式常驻选择激活;路由说明只列出当前实际发布的工具。 +Standard 与 Cordis 发布常驻 OPS 文件工具面:`ops_read_files`、`ops_inspect_local_file`、 +`ops_glob`、`ops_grep` 与 `ops_result`。Minimal 预设不发布常驻文件工具,其会话获得动态加载 +入口并通过[工具发现](工具发现.md)激活高级工具契约;PTC 模式通过类型化 SDK 暴露同一批能力。 +路由说明只列出该作用域当前实际发布的工具。 文件路径以 DSH 会话 cwd 为基准解析。`ops_read_files` 接受 `files` 数组,每项有独立路径、从 1 开始的行偏移和行数上限;单文件操作接受 `path`。Native 引用分页接受原始引用的 `root`。文件工具不接受 `workspace`、`cwd` 或 `file_path` 别名。文本工具优先使用 UTF-8;文档工具遵循各自有界格式/编码契约。glob 与 grep 不遍历符号链接、junction、重解析点、隐藏文件和 `.git`。 @@ -134,9 +140,10 @@ Native 输出文件标题、带行号的可逆转义正文及 revision/续读提 --- -## `dsh-ops/ptc` —— 原生批处理与结果引用 +## PTC 模式 —— 原生批处理与结果引用 -组件默认启用,仅纯 PTC 会话发布工具;保留用户在 profile 中明确关闭的选择。 +`dsh-ops/mode-ptc` 行默认启用,仅在来自 PTC 预设的会话中发布这些工具。宿主行开关保留显式关闭 +状态及已配置的 API 池。 ### `ops_batch` @@ -171,9 +178,10 @@ Native 输出文件标题、带行号的可逆转义正文及 revision/续读提 --- -## `dsh-ops/shell` —— 终端与 shell +## 预设模式 —— 终端与 shell -执行顺序:**工具包 → bash → 宿主 PowerShell**。 +执行顺序:**工具包 → bash → 宿主 PowerShell**。每个已启用模式在对应 OPS 工具可见时,用 +`ops_bash` 替换 Windows 官方 shell(`pwsh`/`pwsh_persistent`);POSIX 会话本就运行宿主的 bash。 Windows x64 上,主包以可选依赖的形式随安装带上固定版本的 bash 载荷(`@dsh-ops/bash-win32-x64@0.2.1`,PortableGit `v2.56.0.windows.2`,GNU bash 5.3.15);也可用 `bashPath` 选择 Git Bash;载荷缺失或不完整时用 `dsh-ops bash-runtime install` 修复或安装托管副本。POSIX 主机使用系统 bash,不随包提供载荷。PowerShell 保持宿主选择。 @@ -215,9 +223,12 @@ Bash 参数错误以失败的 foreground 值返回,不抛出异常。兼容的 --- -## `dsh-ops/hide-official` —— 逐项替换 +## 兼容模式 —— 官方工具保持可见 -默认禁用,配置为 `replaceTools: []`。显式选择后,模型会改用下列经评审的读取/搜索流程。它们的名称、参数和结果与宿主工具不同;不会在官方名称下翻译或转发调用。`run_code`(PTC)呈现的作用域不会被隐藏:它的 transport 不被门禁阻塞,而所选官方工具在该作用域内仍被拒绝。替换契约只覆盖 native 呈现。 +Standard 与 Cordis 会把官方 `read`、`glob`、`grep` 替换为下列经评审的读取/搜索流程;每个 +已启用模式都会替换 Windows 官方 shell。这些流程的名称、参数和结果与宿主工具不同;不会在官方 +名称下翻译或转发调用。`run_code`(PTC)呈现的作用域不会被隐藏:它的 transport 不被门禁阻塞, +而被替换的官方工具在该作用域内仍被拒绝。替换契约只覆盖 native 呈现。 | 官方工具 | OPS 替代工具 | 经评审用途 | @@ -231,15 +242,25 @@ Bash 参数错误以失败的 foreground 值返回,不抛出异常。兼容的 - **glob:** 宿主接受一个模式字符串,裸文件名可匹配任意深度,并包含隐藏及忽略文件。OPS 接受相对 `path` 的 glob 数组;`*.ts` 仅匹配顶层,递归应使用 `**/*.ts`。OPS 支持排除规则和分页,但不遍历符号链接、重解析点、隐藏文件或 `.git`。搜索覆盖范围和排序并不完全相同。 - **grep:** 宿主接受单个 glob 过滤条件和 ripgrep 正则表达式。OPS 使用 Rust regex、相对 glob 数组、可选上下文和输出模式,以及有界分页结果。必须读取完成/截断字段;部分扫描不能证明全局无匹配。宿主的溢出文件和 OPS 分页属于不同流程。 -OPS 调用始终保留自己的会话 cwd 路径基准、权限检查和输出契约。选择替代工具不会扩大权限。需要宿主特定覆盖范围时,应保留对应官方工具。 +OPS 调用始终保留自己的会话 cwd 路径基准、权限检查和输出契约。替换官方工具不会扩大权限。 + +`write`、`edit`、`str_replace_editor` 故意不提供替换。OPS 写入和编辑要求观测到的 `expectedRevision` 并以 CAS 发布;宿主的直接写入和全量匹配编辑不具有相同契约。名称相似的注册项不能证明兼容。Shell、`run_code`、委派、任务、媒体和第三方工具也保持可见,不产生警告列表。 -`write`、`edit`、`str_replace_editor` 故意不提供选择。OPS 写入和编辑要求观测到的 `expectedRevision` 并以 CAS 发布;宿主的直接写入和全量匹配编辑不具有相同契约。名称相似的注册项不能证明兼容。Shell、`run_code`、委派、任务、媒体和第三方工具也保持可见,不产生警告列表。 +`dsh-ops/compat` 行**默认禁用**且不接受任何配置。启用后所有官方工具保持可见可调用,OPS 工具 +继续发布,并按作用域解除隐藏且不卸载任何行;禁用后恢复隐藏。本版本没有逐项 `replaceTools` +选择——已移除的 `dsh-ops/hide-official` 行是 bundle 之外的旧模块,请删除为它保存的覆盖;其旧 +`strict`/`scope`/`additionalOfficialTools` 值仅由该旧模块校验,在此不授予任何隐藏权限。 -所选 OPS 替代工具必须已注册、可见,并且是 OPS 拥有的确切 Native 定义。同名或 schema 完全相同的外部注册不能满足就绪条件。即使 schema 字节不变,所有权变化也会重新检查;OPS PTC 适配器创建的 Native 映射保留受跟踪的所有权。仅有 PTC 数据 API 不能替换 Native 工具。任一所选替代工具不可用时,仅受影响作用域以 `READINESS_BLOCKED` 拒绝运行,并撤销该作用域自身的替代隐藏;健康作用域及继承的权限限制保持生效。移除选择、卸载或禁用组件会恢复对应官方工具。旧 `strict`/`scope`/`additionalOfficialTools` 会校验但不生效,必须显式提供 `replaceTools` 才会隐藏。 +隐藏按 agent 作用域进行,且只在对应 OPS 工具已注册、可见并且是 OPS 拥有的确切 Native 定义时生效。 +同名或 schema 完全相同的外部注册不符合条件;对应工具缺失时官方工具保持可见,而不是阻塞提示词 +组装。即使 schema 字节不变,所有权变化也会重新检查;OPS PTC 适配器创建的 Native 映射保留受 +跟踪的所有权。禁用模式行或开启兼容模式会恢复对应官方工具。 -常驻的 `ops_read_files` 不是经评审的 `read` 替代项。选择 `read` 前,应配置发现组件 `alwaysLoaded: [inspect_local_file]`,或确保该单文件能力已在当前 Native 作用域激活。`glob` 和 `grep` 同样需要就绪。内部展示模式检查允许发现组件在启动时发布这些已配置工具;普通提示词组装和工具执行在就绪前仍会被拒绝。 +常驻的 `ops_read_files` 不是 `read` 的对应项;Standard 与 Cordis 发布 +`ops_inspect_local_file`、`ops_glob` 与 `ops_grep` 作为经评审的对应工具。 -组件同时导出 StandardSchemaV1 验证器与公开 JSON schema 描述,供宿主内置行表单使用。可选浏览器多选页面需要显式客户端承载行迁移,见[配置参考](配置参考.md)。 +模式行同时导出 StandardSchemaV1 验证器与公开 JSON schema 描述,供宿主内置行表单使用。可选 +浏览器设置页面需要显式客户端承载行迁移,见[配置参考](配置参考.md)。 --- diff --git "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" index 87ee5f38..7f1f770d 100644 --- "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" +++ "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" @@ -6,21 +6,19 @@ 活动行通过宿主 Settings/ConfigEditor 提供严格校验的设置。接受的更改通过 profile 写入器持久化并重载所属行;版本过期或无效更改不会修改已保存的值。关闭的行须先用宿主行开关重新启用,等待中的服务须先就绪,之后才能编辑。 -默认 bundle 保留 `dsh-ops/file` 及稳定 ID `dsh-ops-file`,已有带名称覆盖继续匹配。固定宿主下,此默认配置不会启用自定义浏览器页面。 +默认 bundle 声明下列八行,且不再包含旧的 `shell`、`file`、`ptc`、`hide-official` 行。为已移除行保存的 profile 覆盖没有匹配目标,会被宿主 patch 合成器报告,必须删除。固定宿主下,默认配置不会启用自定义浏览器页面。 ## 可选浏览器设置页面 -配置裸包名客户端承载行后,宿主插件管理器为替换、发现和 shell 行提供设置页面。默认七行 bundle 保留旧文件模块名,不启用这些页面。更改在**保存更改**成功前仅为草稿。 +选择客户端承载 bundle 后,宿主插件管理器为四个模式行和发现行提供设置页面。默认 bundle 保留内置行表单,不启用这些页面。更改在**保存更改**成功前仅为草稿。 版本冲突或宿主拒绝保存时保留草稿,供检查;**恢复已保存值**会读取最新保存值。 接受的更改通过宿主配置写入器持久化,并重新加载对应行。 -- **替换:**分别选择 `read`、`glob` 和 `grep`,所选工具使用 OPS 契约。 - 请在发现设置中始终加载所选对应能力(`read` 对应 `inspect_local_file`,其余为 `glob` 或 `grep`),或在选择替换前激活它们。`read_files` 不满足单文件 `read` 替换契约。 +- **模式行:**启用 Bash 发布、填写绝对执行器路径覆盖,或选择检测到的受管理 Bash/Git Bash。 + PTC 模式行还会通过共享 schema 提供自己的 API 池。选择运行时仅填写草稿路径,需要显式保存。 + 清空覆盖值可继承配置默认值。安装运行时不会自动更改这一选择。 - **发现:**选择允许池、始终加载子集以及 1 到 5 的结果数量上限。 移除允许能力时,会在同一次保存中将其从始终加载子集移除。 -- **Shell:**启用 Bash、填写绝对执行器路径覆盖,或选择检测到的受管理 Bash/Git Bash。 - 选择运行时仅填写草稿路径,需要显式保存。清空覆盖值可继承配置默认值。 - 安装运行时不会自动更改这一选择。 Bash 安装/删除与 PowerShell 升级按钮首先显示计划,随后需要显式执行已检查的计划。 启动后取消可能已发生更改,此时会报告影响未知。受管理 Bash 删除仅处理具有所有权凭据的安装, @@ -28,10 +26,7 @@ Bash 安装/删除与 PowerShell 升级按钮首先显示计划,随后需要 保留 Windows PowerShell 5.1,完成后需要重启 DSH;宿主自定义 `pwshPath` 需单独检查。 打开页面不会运行安装程序。 -客户端承载行使用稳定 ID `dsh-ops-file` 和裸包名 `dsh-ops` 承载这些浏览器页面,服务端仍为文件组件; -`dsh-ops/file` 导出保持兼容。关闭此承载行会卸载全部自定义设置页面,请使用宿主的 -**内置行开关**重新启用。没有自动表单回退。目标行被关闭时,也必须先重新启用才能编辑设置。 -发现组件行与可选客户端承载行都启用后,才可使用其自定义设置页面。 +客户端承载即用嵌套的 `dsh-ops/lib/profiles/client` bundle 替代默认 `dsh-ops` bundle。该嵌套 bundle 由规范 bundle 生成,只把 Standard 模式行改名为包根 `dsh-ops`,好让客户端半边加载这些浏览器页面;其余七行不变,该行仍应用 Standard 模式。移除承载 bundle 选择会卸载全部自定义设置页面,默认 bundle 继续使用内置表单。没有自动表单回退。目标行被关闭时,也必须先重新启用才能编辑设置。 ### 启用可选浏览器承载行 @@ -42,9 +37,10 @@ dsh-ops settings-profile --profile-dir --host-anchor ` 检查所选 bash,报告中的 `restricted: yes/no` 即受限模式能否运行。见 [常见问题](常见问题.md#受限模式下-ops_bash-无法运行自定义-bashpath-或-git-for-windows)。 -- `deniedHostTools`:`deny-host-shell` 策略下拒绝的宿主工具名列表。 -- `configUpdateVersion`:配置迁移的完成标记(当前为 `2`:更新 1 撤销旧的隐式系统 bash 选择,更新 2 删除已弃用的 FastCtx 键)。不要删除迁移标记,否则会重新自检。 +- `configUpdateVersion`:配置迁移的完成标记(当前为 `2`:更新 1 撤销旧的隐式系统 bash 选择,更新 2 删除已弃用的 FastCtx 键)。每个模式行都接受该键;不要删除迁移标记,否则会重新自检。 关闭组件行或保存 `capture: false` 会立即停止事件采集、取消尚未开始的文件操作,并在宿主完成变更前等待已开始的 I/O。停用不会触发最终归并;已有 NDJSON 可恢复,中断归并留下的临时文件可能保留。重新开启只采集后续事件,不补录关闭期间的事件。 - panel 行默认关闭,不注册监听、不写文件。启用后 `capture` 默认为 `true`;旧配置显式 `false` 仍不采集,不会被静默更改。宿主无法原子地清除旧字段并同时停用行,因此保留兼容设置,供操作者显式编辑。采集到的事件写入会话工作目录: @@ -169,36 +170,36 @@ config: --- -## 逐项替换官方工具(`dsh-ops/hide-official`) - -该行**默认禁用**,`replaceTools` 默认是 `[]`。直接启用未配置的行不会隐藏任何工具。可从经评审的官方名称 `read`、`glob`、`grep` 中逐项选择;未选工具保持可见且不产生警告。所选项会改用不同的操作流程,请先阅读[映射与覆盖范围差异](组件参考.md)。 - -| 键 | 类型 | 默认 | 含义 | -| --- | --- | --- | --- | -| `replaceTools` | 字符串数组 | `[]` | 要替换的官方工具;只接受 `read`、`glob`、`grep`,重复名称去重。未知键和不支持的名称会在错误中点名。 | - -例如,只替换官方 read、grep,保留官方 glob: - -```yaml -- name: dsh-ops/hide-official - config: - replaceTools: [read, grep] -``` - -设为 `replaceTools: []` 或禁用该行即可恢复全部官方工具。每个作用域中,所选 OPS 替代工具必须是已注册且可见的确切 OPS Native 定义;同名外部工具或仅有 PTC 数据 API 均不符合条件。若其中任一工具缺失,该作用域会撤销隐藏,并以 `READINESS_BLOCKED` 拒绝提示词组装和执行;健康作用域仍可使用。恢复替代工具、移除相应选择或禁用该行即可解除。无需注册其它无关的 OPS 工具。 +## 兼容模式(`dsh-ops/compat`) -Native 默认只保留精简核心工具。选择上例前,应先为发现组件配置 `alwaysLoaded: [inspect_local_file, grep]`,或先激活这些 Native 能力。`read` 仍映射到 `inspect_local_file`,常驻 `read_files` 不能满足此选择。撤销或覆盖受拥有的替代定义会立即重新检查就绪状态,即使 schema 字节完全相同。 +该行**默认禁用**且不接受任何配置。启用它只会设置一个运行时开关:每个已启用的模式行继续发布 +自己的 OPS 工具,但不隐藏任何官方工具——`read`、`glob`、`grep` 与 Windows 官方 shell 都保持 +可见可调用。禁用它即可恢复各已启用模式行执行的隐藏,且不卸载任何行。 -**从 0.3 迁移:** 仅为兼容旧配置,接受并按旧类型和取值校验 `strict`、`scope`、`additionalOfficialTools`;它们均不授予隐藏权限,包括 `scope: extended` 和别名列表。只有旧键的既有行会变为无操作。即使保留旧键,`replaceTools` 也是唯一选择来源。本组件不改写已保存的 profile。 +- 隐藏按 agent 作用域进行,且只在该作用域内对应 OPS 工具可见时生效。对应工具缺失时官方工具 + 保持可见,而不是移除一项能力。 +- 该开关是配置事实,不改变模式行发布的工具;开启期间 OPS 对应工具仍可调用。 +- 兼容模式会降低 token 效率与模型能力,因为两套工具面都可见。优先只启用 profile 真正需要的 + 模式行,而不是长期开启兼容模式。 +- 本版本没有逐项 `replaceTools` 选择。已移除的 `dsh-ops/hide-official` 行是 bundle 之外的 + 旧模块,请删除为它保存的覆盖;其旧 `strict`、`scope`、`additionalOfficialTools` 键仅由 + 该旧模块校验,在此不授予任何权限。 -Cordis 通过 `StandardSchemaV1` 校验配置;独立的公共设置描述在内置 Settings/ConfigEditor 中提供可编辑数组。成功保存后持久化并重载对应行。自定义浏览器多选界面仍需单独实现。 +兼容模式没有可编辑的设置页面,使用宿主行开关即可。Cordis 通过 `StandardSchemaV1` 校验模式 +配置;上述可选浏览器承载行用于编辑模式行与发现行。 --- ## 提示词注册 -提示以作用域运行时段落注册,不写入 AGENTS.md。关闭 shell 撤回其路由;关闭 file 撤回 Native 文件指引。新 Native 文件面仅常驻 ops_read_files,发现组件发布 ops_discover_tools,三个平台的 shell 行另有 ops_bash(Windows 为固定载荷,POSIX 为发行版 bash)。高级工具通过发现或显式始终加载启用。PTC 拥有独立类型化 SDK,并保持当前权限检查。已批准的 BM-03 预算对文件/发现 schema 执行 ≤400 token 上限。Bash 启动常驻不变,其 schema 与完整启动总数另列;缺少 Bash 不能满足 Windows 就绪检查。 +提示以作用域运行时段落注册,不写入 AGENTS.md。每个已启用模式行只为来自其预设的会话发布 +路由指引,且仅在其 OPS 工具于该作用域可见时发布;关闭该行即撤回。发现行发布其所发布能力的 +指引,兼容模式不发布任何指引。Standard/Cordis 发布常驻 OPS 文件工具;discovery 行启用时 +所有 native 会话(含 Minimal)获得发现入口;PTC 拥有独立类型化 SDK 并保持当前权限检查。高级工具通过发现或显式始终加载启用; +运行中的模式已常驻发布的工具不会再作为发现选项提供。 +已批准的 BM-03 预算对文件/发现 schema 执行 ≤400 token 上限。Bash 启动常驻不变,其 schema 与 +完整启动总数另列;缺少 Bash 不能满足 Windows 就绪检查。 --- @@ -210,16 +211,19 @@ Cordis 通过 `StandardSchemaV1` 校验配置;独立的公共设置描述在 ## 宿主设置表单 -已启用的 shell、discovery 和官方工具替换行向宿主设置服务提供经过校验的字段。 +每个已启用的行向宿主设置服务提供其支持且经过校验的字段。 保存使用表单读取时的配置版本;版本过期或选项无效时,在写入前拒绝更改。 宿主持久化成功后重新加载对应行,使运行中的设置与已保存选择一致,同时保留其他行字段。 禁用的行没有可编辑表单;请先使用宿主内置的行开关重新启用。 -Shell 设置描述除 Bash 选择外,还包含路由策略、提示指引和系统 shell 回退开关;内部版本标记、仅用于文件组件及已移除 FastCtx 的兼容字段明确不提供编辑。Discovery 展示全部七个接受的字段,包括 `binaryPath`、`required` 和 `denyCredentialReads`。发布门禁将组件入口、目标平台、工具声明、保留的宿主命令以及 bundle 中显式默认值与运行时定义逐项比较。 +模式行描述暴露 Bash 发布开关与绝对执行器覆盖;PTC 模式还通过共享 schema 暴露自己的 API 池。 +内部版本标记、已移除 file/shell 行的键及已移除 FastCtx 的兼容字段明确不提供编辑。Discovery +展示全部七个接受的字段,包括 `binaryPath`、`required` 和 `denyCredentialReads`。发布门禁将 +组件入口、目标平台、工具声明、保留的宿主命令以及 bundle 中显式默认值与运行时定义逐项比较。 运行时操作使用宿主已认证的网关及受管理的子进程服务。安装、删除和升级必须先预览, 再执行仍然有效的预览;过期、内容变化或已使用的计划会被拒绝。同一时间只执行一个操作。 -卸载对应 shell 组件会取消尚未完成的操作并等待结束;启动后取消可能已造成更改,副作用标为未知。 +卸载对应模式行会取消尚未完成的操作并等待结束;启动后取消可能已造成更改,副作用标为未知。 Bash 安装拒绝包含无归属版本目录的存储。PowerShell 操作更新标准 WinGet 安装,随后需要重启 DSH;宿主显式设置的 `pwshPath` 仍然优先。安装器输出留在宿主日志中;如果 WinGet 需要交互输入, 请在终端运行预览中显示的命令。 diff --git a/dsh-plugin.json b/dsh-plugin.json index 0da9d40d..0b8005bb 100644 --- a/dsh-plugin.json +++ b/dsh-plugin.json @@ -3,7 +3,7 @@ "manifestVersion": "0.15", "id": "dsh-ops.native", "name": "dsh-ops", - "version": "0.4.1", + "version": "0.5.0", "license": "MIT", "facets": { "host": { @@ -50,19 +50,19 @@ ], "x-prompt-sections": [ { - "name": "dsh-ops:repository-tooling:file", + "name": "dsh-ops:repository-tooling:mode-standard", "order": 3250, - "summary": "File operations only; follows the file component visibility." + "summary": "Standard-preset routing: prefer the resident OPS file tools and ops_bash, replacing read/glob/grep once their OPS counterparts are visible; published only for Standard-preset sessions when the row is on and promptPolicy is enabled." }, { - "name": "dsh-ops:repository-tooling:shell", + "name": "dsh-ops:repository-tooling:mode-ptc", "order": 3251, - "summary": "Prefer bash; use visible host PowerShell for Windows-native functionality without overriding its executor; removed when the shell component is off." + "summary": "PTC-preset routing: scoped progressive PTC SDK guidance for PTC-preset sessions; absent outside PTC-only presentation. PTC rows publish no native routing text." }, { - "name": "dsh-ops:repository-tooling:ptc", + "name": "dsh-ops:repository-tooling:mode-cordis", "order": 3252, - "summary": "Scoped progressive PTC SDK guidance; absent in Native/both presentation." + "summary": "Cordis-preset routing: prefer the resident OPS file tools and ops_bash, replacing read/glob/grep once their OPS counterparts are visible; published only for Cordis-preset sessions when the row is on and promptPolicy is enabled." } ], "x-tool-namespaces": [ @@ -99,35 +99,52 @@ "tools": [ "bash" ], - "shellPermission": "shell component enabled AND publishBashTool AND authoritative per-session DSH policy; restricted modes require host confinement" + "shellPermission": "Mode row enabled with the matching preset AND publishBashTool AND authoritative per-session DSH policy; restricted modes require host confinement" } ], "x-components": [ { - "id": "dsh-ops-ptc", - "entry": "dsh-ops/ptc", + "id": "dsh-ops-mode-minimal", + "entry": "dsh-ops/mode-minimal", "defaultEnabled": true, "platforms": ["win32", "linux", "darwin"], - "architectures": ["x64", "arm64"], "targets": ["win32-x64", "linux-x64", "darwin-arm64", "darwin-x64"], - "presentation": "PTC-only; reader/result/SDK loader resident, other APIs become visible on the next agent step", - "runtime": "independent native Rust file worker", - "tools": ["ops_inspect_local_file","ops_grep","ops_glob","ops_replace","ops_write_file","ops_edit_file","ops_diff","ops_stat","ops_copy","ops_move","ops_remove","ops_read_files","ops_read_table","ops_read_docx","ops_read_pdf","ops_batch","ops_result","ops_load_sdk"], - "permissions": "Per-call original DSH sandboxPolicy and sandbox.confine (host full/partial enforcement reported); full access bypasses confinement and permits explicit operation roots; subprocess owns cancellation and credential scrub" + "presentation": "Minimal-preset sessions only; native scopes publish on the next admitted model step.", + "tools": ["ops_bash"], + "notes": "Windows shell replacement: on win32 the row replaces the official pwsh tools with ops_bash; POSIX keeps the host bash, so no shell tool is published there. Minimal publishes no routing section; advanced tools arrive through the discovery row." }, { - "id": "dsh-ops-shell", - "entry": "dsh-ops/shell", + "id": "dsh-ops-mode-standard", + "entry": "dsh-ops/mode-standard", "defaultEnabled": true, "platforms": ["win32", "linux", "darwin"], - "targets": ["win32-x64", "linux-x64", "darwin-arm64", "darwin-x64"] + "targets": ["win32-x64", "linux-x64", "darwin-arm64", "darwin-x64"], + "presentation": "Native scopes; the resident file tools replace read/glob/grep and the Windows shell replacement publishes ops_bash.", + "tools": ["ops_inspect_local_file", "ops_glob", "ops_grep", "ops_read_files", "ops_result", "ops_bash"], + "notes": "Windows shell replacement: on win32 the row replaces the official pwsh tools with ops_bash; POSIX keeps the host bash. Advanced capabilities are discovered through the discovery row." }, { - "id": "dsh-ops-file", - "entry": "dsh-ops/file", + "id": "dsh-ops-mode-ptc", + "entry": "dsh-ops/mode-ptc", "defaultEnabled": true, "platforms": ["win32", "linux", "darwin"], - "targets": ["win32-x64", "linux-x64", "darwin-arm64", "darwin-x64"] + "architectures": ["x64", "arm64"], + "targets": ["win32-x64", "linux-x64", "darwin-arm64", "darwin-x64"], + "presentation": "PTC-only; reader/result/SDK loader resident, other APIs become visible on the next agent step.", + "runtime": "independent native Rust file worker", + "tools": ["ops_inspect_local_file","ops_grep","ops_glob","ops_replace","ops_write_file","ops_edit_file","ops_diff","ops_stat","ops_copy","ops_move","ops_remove","ops_read_files","ops_read_table","ops_read_docx","ops_read_pdf","ops_batch","ops_result","ops_load_sdk"], + "permissions": "Per-call original DSH sandboxPolicy and sandbox.confine (host full/partial enforcement reported); full access bypasses confinement and permits explicit operation roots; subprocess owns cancellation and credential scrub", + "notes": "PTC-preset sessions only. On win32 the row also replaces the official pwsh tools with ops_bash; POSIX keeps the host bash." + }, + { + "id": "dsh-ops-mode-cordis", + "entry": "dsh-ops/mode-cordis", + "defaultEnabled": true, + "platforms": ["win32", "linux", "darwin"], + "targets": ["win32-x64", "linux-x64", "darwin-arm64", "darwin-x64"], + "presentation": "Native scopes; the resident file tools replace read/glob/grep and the Windows shell replacement publishes ops_bash.", + "tools": ["ops_inspect_local_file", "ops_glob", "ops_grep", "ops_read_files", "ops_result", "ops_bash"], + "notes": "Windows shell replacement: on win32 the row replaces the official pwsh tools with ops_bash; POSIX keeps the host bash. Advanced capabilities are discovered through the discovery row." }, { "id": "dsh-ops-discovery", @@ -138,6 +155,16 @@ "presentation": "Native scopes; activation on the next admitted model step", "tools": ["ops_discover_tools"] }, + { + "id": "dsh-ops-compat", + "entry": "dsh-ops/compat", + "defaultEnabled": false, + "platforms": ["win32", "linux", "darwin"], + "replaceTools": [], + "retainedOfficialCommand": { "win32": "pwsh", "linux": "bash", "darwin": "bash" }, + "readiness": "Compatibility fallback, off by default. When enabled, every official tool stays visible and no OPS replacement is applied; OPS tools remain published. This affects token efficiency and model capability, so enable it only as a safety measure when DSH cannot work properly with OPS active.", + "tools": [] + }, { "id": "dsh-ops-panel", "entry": "dsh-ops/panel", @@ -153,15 +180,6 @@ "defaultEnabled": false, "presentation": "Optional read-only dsh-TUI panel API v1", "tools": [] - }, - { - "id": "dsh-ops-hide-official", - "entry": "dsh-ops/hide-official", - "defaultEnabled": false, - "platforms": ["win32", "linux", "darwin"], - "replaceTools": [], - "retainedOfficialCommand": { "win32": "pwsh", "linux": "bash", "darwin": "bash" }, - "readiness": "Opt-in per-tool read/glob/grep alternatives, selected by replaceTools (default empty). Only selected official tools are hidden after their OPS counterparts are registered in the same scope. Missing selected counterparts block that scope; unselected tools stay visible without warning lists." } ] }, diff --git a/native/Cargo.lock b/native/Cargo.lock index 47b4f220..0f17a9d2 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -138,7 +138,7 @@ dependencies = [ [[package]] name = "dsh-ops-core" -version = "0.4.1" +version = "0.5.0" dependencies = [ "cap-fs-ext", "cap-std", @@ -153,11 +153,11 @@ dependencies = [ [[package]] name = "dsh-ops-darwin-metadata" -version = "0.4.1" +version = "0.5.0" [[package]] name = "dsh-ops-windows-metadata" -version = "0.4.1" +version = "0.5.0" dependencies = [ "winapi", "windows-permissions", @@ -354,7 +354,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "ops-batch" -version = "0.4.1" +version = "0.5.0" dependencies = [ "cap-std", "ops-files", @@ -366,7 +366,7 @@ dependencies = [ [[package]] name = "ops-documents" -version = "0.4.1" +version = "0.5.0" dependencies = [ "flate2", "ops-files", @@ -380,7 +380,7 @@ dependencies = [ [[package]] name = "ops-files" -version = "0.4.1" +version = "0.5.0" dependencies = [ "cap-fs-ext", "cap-std", @@ -397,7 +397,7 @@ dependencies = [ [[package]] name = "ops-protocol" -version = "0.4.1" +version = "0.5.0" dependencies = [ "regex", "serde", @@ -406,7 +406,7 @@ dependencies = [ [[package]] name = "ops-results" -version = "0.4.1" +version = "0.5.0" dependencies = [ "cap-fs-ext", "cap-std", @@ -421,7 +421,7 @@ dependencies = [ [[package]] name = "ops-search" -version = "0.4.1" +version = "0.5.0" dependencies = [ "cap-fs-ext", "cap-std", diff --git a/native/Cargo.toml b/native/Cargo.toml index 5abd6591..ab9fa90a 100644 --- a/native/Cargo.toml +++ b/native/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dsh-ops-core" -version = "0.4.1" +version = "0.5.0" edition = "2024" license = "MIT" rust-version = "1.88" diff --git a/native/crates/ops-batch/Cargo.toml b/native/crates/ops-batch/Cargo.toml index d990f98e..2fd34982 100644 --- a/native/crates/ops-batch/Cargo.toml +++ b/native/crates/ops-batch/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ops-batch" -version = "0.4.1" +version = "0.5.0" edition = "2024" license = "MIT" rust-version = "1.88" diff --git a/native/crates/ops-documents/Cargo.toml b/native/crates/ops-documents/Cargo.toml index 36fbf053..f3d4f079 100644 --- a/native/crates/ops-documents/Cargo.toml +++ b/native/crates/ops-documents/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ops-documents" -version = "0.4.1" +version = "0.5.0" edition = "2024" license = "MIT" rust-version = "1.88" diff --git a/native/crates/ops-files/Cargo.toml b/native/crates/ops-files/Cargo.toml index 60441a10..f31239b3 100644 --- a/native/crates/ops-files/Cargo.toml +++ b/native/crates/ops-files/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ops-files" -version = "0.4.1" +version = "0.5.0" edition = "2024" license = "MIT" rust-version = "1.88" diff --git a/native/crates/ops-protocol/Cargo.toml b/native/crates/ops-protocol/Cargo.toml index 7bbfe852..409f9037 100644 --- a/native/crates/ops-protocol/Cargo.toml +++ b/native/crates/ops-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ops-protocol" -version = "0.4.1" +version = "0.5.0" edition = "2024" license = "MIT" rust-version = "1.88" diff --git a/native/crates/ops-results/Cargo.toml b/native/crates/ops-results/Cargo.toml index ebb8437e..c29b95e2 100644 --- a/native/crates/ops-results/Cargo.toml +++ b/native/crates/ops-results/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ops-results" -version = "0.4.1" +version = "0.5.0" edition = "2024" license = "MIT" rust-version = "1.88" diff --git a/native/crates/ops-search/Cargo.toml b/native/crates/ops-search/Cargo.toml index 120b03a4..1a85506a 100644 --- a/native/crates/ops-search/Cargo.toml +++ b/native/crates/ops-search/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ops-search" -version = "0.4.1" +version = "0.5.0" edition = "2024" license = "MIT" rust-version = "1.88" diff --git a/native/darwin-metadata/Cargo.toml b/native/darwin-metadata/Cargo.toml index bfae7b7e..2dbbacd0 100644 --- a/native/darwin-metadata/Cargo.toml +++ b/native/darwin-metadata/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dsh-ops-darwin-metadata" -version = "0.4.1" +version = "0.5.0" edition = "2024" license = "MIT" rust-version = "1.88" diff --git a/native/windows-metadata/Cargo.toml b/native/windows-metadata/Cargo.toml index fe255af9..2490f46c 100644 --- a/native/windows-metadata/Cargo.toml +++ b/native/windows-metadata/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dsh-ops-windows-metadata" -version = "0.4.1" +version = "0.5.0" edition = "2024" license = "MIT" rust-version = "1.88" diff --git a/package-lock.json b/package-lock.json index 5ec3fef5..9129ea7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dsh-ops", - "version": "0.4.1", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dsh-ops", - "version": "0.4.1", + "version": "0.5.0", "cpu": [ "x64", "arm64" diff --git a/package.json b/package.json index e0536489..23e383c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-ops", - "version": "0.4.1", + "version": "0.5.0", "publishConfig": { "access": "public" }, @@ -92,6 +92,36 @@ "./hide-official/package.json": "./lib/components/hide-official/package.json", "./hide-official/locale/*.json": "./lib/components/hide-official/locale/*.json", "./hide-official/icon": "./lib/components/hide-official/icon.png", + "./mode-minimal": { + "default": "./lib/components/mode-minimal/index.js" + }, + "./mode-minimal/package.json": "./lib/components/mode-minimal/package.json", + "./mode-minimal/locale/*.json": "./lib/components/mode-minimal/locale/*.json", + "./mode-minimal/icon": "./lib/components/mode-minimal/icon.png", + "./mode-standard": { + "default": "./lib/components/mode-standard/index.js" + }, + "./mode-standard/package.json": "./lib/components/mode-standard/package.json", + "./mode-standard/locale/*.json": "./lib/components/mode-standard/locale/*.json", + "./mode-standard/icon": "./lib/components/mode-standard/icon.png", + "./mode-ptc": { + "default": "./lib/components/mode-ptc/index.js" + }, + "./mode-ptc/package.json": "./lib/components/mode-ptc/package.json", + "./mode-ptc/locale/*.json": "./lib/components/mode-ptc/locale/*.json", + "./mode-ptc/icon": "./lib/components/mode-ptc/icon.png", + "./mode-cordis": { + "default": "./lib/components/mode-cordis/index.js" + }, + "./mode-cordis/package.json": "./lib/components/mode-cordis/package.json", + "./mode-cordis/locale/*.json": "./lib/components/mode-cordis/locale/*.json", + "./mode-cordis/icon": "./lib/components/mode-cordis/icon.png", + "./compat": { + "default": "./lib/components/compat/index.js" + }, + "./compat/package.json": "./lib/components/compat/package.json", + "./compat/locale/*.json": "./lib/components/compat/locale/*.json", + "./compat/icon": "./lib/components/compat/icon.png", "./client": { "default": "./lib/client.js" } diff --git a/scripts/lib/build/client-profile.mjs b/scripts/lib/build/client-profile.mjs index c410f7a1..9c46081a 100644 --- a/scripts/lib/build/client-profile.mjs +++ b/scripts/lib/build/client-profile.mjs @@ -1,12 +1,14 @@ -/** Generate the opt-in layer from canonical defaults; only the file specifier differs. */ +/** Generate the opt-in layer from canonical defaults; only the standard-mode specifier differs. */ import { readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' import { parse, stringify } from 'yaml' export function buildClientProfile(root) { const patches = parse(readFileSync(path.join(root, 'cordis.patch.yml'), 'utf8')) const rows = patches.flatMap(patch => patch.insert ?? []) - const files = rows.filter(row => row.id === 'dsh-ops-file') - if (rows.length !== 7 || files.length !== 1 || files[0].name !== 'dsh-ops/file') throw new Error('Cannot derive the optional settings profile from this bundle layout.') - files[0].name = 'dsh-ops' + // The carrier row names the package root so the host loads the client half; it + // must be the standard mode row, whose package-root entry applies that mode. + const carrier = rows.filter(row => row.id === 'dsh-ops-mode-standard') + if (rows.length !== 8 || carrier.length !== 1 || carrier[0].name !== 'dsh-ops/mode-standard') throw new Error('Cannot derive the optional settings profile from this bundle layout.') + carrier[0].name = 'dsh-ops' writeFileSync(path.join(root, 'lib/profiles/client/cordis.patch.yml'), '# Generated from the canonical bundle; do not edit.\n' + stringify(patches)) } diff --git a/scripts/lib/catalogue-checks.mjs b/scripts/lib/catalogue-checks.mjs index 7cd94a5e..2316374c 100644 --- a/scripts/lib/catalogue-checks.mjs +++ b/scripts/lib/catalogue-checks.mjs @@ -4,6 +4,7 @@ import { COMPONENTS, componentEntry, componentEnabled } from '../../lib/componen import { DISCOVERY_CAPABILITIES } from '../../lib/capabilities/registry.js' import { publicToolName } from '../../lib/policy.js' import { platformSurface } from '../../lib/official-tool-catalog.js' +import { residentTools } from '../../lib/modes/plan.js' export function defaultChecks(component, config, resolve) { const defaults = resolve({}) @@ -12,21 +13,33 @@ export function defaultChecks(component, config, resolve) { ]) } +/** Components that need the native runtime target matrix declared. */ +const RUNTIME_COMPONENTS = new Set(['mode-minimal', 'mode-standard', 'mode-ptc', 'mode-cordis', 'discovery', 'panel']) + +/** The tool names one component declaration advertises for its shipped surface. */ +function declaredTools(component) { + if (component === 'discovery') return DISCOVERY_CAPABILITIES.map(entry => publicToolName(entry.rawName)) + if (component === 'compat' || component === 'panel' || component === 'tui') return [] + if (component === 'mode-minimal') return ['ops_bash'] + if (component === 'mode-standard' || component === 'mode-cordis') { + return [...residentTools(component === 'mode-standard' ? 'standard' : 'cordis'), 'ops_bash'].sort() + } + return undefined +} + export function componentChecks(components, platform = process.platform) { const checks = [], check = (...value) => checks.push(value) - const ptc = components.find(row => row.id === 'dsh-ops-ptc') + const targets = components.find(row => row.id === 'dsh-ops-mode-ptc')?.targets check(components.length === COMPONENTS.length && new Set(components.map(row => row.id)).size === COMPONENTS.length, 'component declarations are complete and unique') for (const component of COMPONENTS) { const row = components.find(value => value.id === `dsh-ops-${component}`) check(row?.entry === componentEntry(component), `${component} declaration uses its exported entry`) check(row?.defaultEnabled === componentEnabled(component, platform), `${component} declaration matches its enablement default`) - const hasRuntimeTarget = ['ptc', 'shell', 'file', 'discovery', 'panel'].includes(component) - check(isDeepStrictEqual(row?.targets, hasRuntimeTarget ? ptc?.targets : undefined), `${component} target declaration matches its runtime requirements`) - const tools = component === 'discovery' ? DISCOVERY_CAPABILITIES.map(entry => publicToolName(entry.rawName)) - : ['panel', 'tui'].includes(component) ? [] : undefined - if (component !== 'ptc') check(isDeepStrictEqual(row?.tools, tools), `${component} tool declaration matches its catalogue projection`) + check(isDeepStrictEqual(row?.targets, RUNTIME_COMPONENTS.has(component) ? targets : undefined), `${component} target declaration matches its runtime requirements`) + const tools = declaredTools(component) + if (tools !== undefined) check(isDeepStrictEqual([...(row?.tools ?? [])].sort(), tools), `${component} tool declaration matches its catalogue projection`) } - const retained = Object.fromEntries(['win32', 'linux', 'darwin'].map(platform => [platform, platformSurface(platform).retainedCommand])) - check(isDeepStrictEqual(components.find(row => row.id === 'dsh-ops-hide-official')?.retainedOfficialCommand, retained), 'retained official commands match platform policy') + const retained = Object.fromEntries(['win32', 'linux', 'darwin'].map(entry => [entry, platformSurface(entry).retainedCommand])) + check(isDeepStrictEqual(components.find(row => row.id === 'dsh-ops-compat')?.retainedOfficialCommand, retained), 'retained official commands match platform policy') return checks } diff --git a/scripts/validate-manifest.mjs b/scripts/validate-manifest.mjs index 6746141c..1b18715e 100644 --- a/scripts/validate-manifest.mjs +++ b/scripts/validate-manifest.mjs @@ -22,6 +22,7 @@ import { componentChecks, defaultChecks } from './lib/catalogue-checks.mjs' import { parse as parseYaml } from 'yaml' import { IN_PROCESS_TOOLS, + MODE_SECTION_ORDERS, TOOLING_SECTION, TOOL_PREFIX, publicToolName, @@ -33,6 +34,7 @@ import { resolveTuiConfig } from '../lib/components/tui/index.js' import { resolvePanelConfig } from '../lib/components/panel/index.js' import { PTC_API_TOOLS, definition } from '../lib/ptc/contract.js' import { resolveConfig, resolveDiscoveryConfig } from '../lib/config.js' +import { resolveModeConfig } from '../lib/modes/config.js' import { COMPONENTS, componentEntry, componentEnabled } from '../lib/components/defaults.js' import { resolveHidingConfig } from '../lib/official-tool-hiding.js' import { evaluatePluginCompatibility } from '@deepseek-ai/dsh-app-boot' @@ -229,12 +231,12 @@ if (typeof patch === 'string' && fs.existsSync(path.join(PACKAGE_ROOT, patch))) check(Array.isArray(document), 'the bundle patch is a list of loader patch entries') try { const composed = composeBundle([document]) - check(composed.length === 7, 'the host composes seven rows without skipped patch targets') - } catch (error) { check(false, 'the host composes seven rows without skipped patch targets', String(error?.message ?? error)) } + check(composed.length === COMPONENTS.length, `the host composes ${COMPONENTS.length} rows without skipped patch targets`) + } catch (error) { check(false, `the host composes ${COMPONENTS.length} rows without skipped patch targets`, String(error?.message ?? error)) } const rows = Array.isArray(document) ? document.flatMap((entry) => (Array.isArray(entry?.insert) ? entry.insert : [])) : [] - check(rows.length === 7, 'the bundle declares exactly seven components') + check(rows.length === COMPONENTS.length, `the bundle declares exactly ${COMPONENTS.length} components`) check(!rows.some(candidate => candidate?.name === `${pkg.name}/background`), 'the bundle no longer inserts the removed background component') for (const component of COMPONENTS) { const row = rows.find(candidate => candidate?.name === componentEntry(component)) @@ -242,10 +244,9 @@ if (typeof patch === 'string' && fs.existsSync(path.join(PACKAGE_ROOT, patch))) if (!row) continue check((row.disabled === true) === !componentEnabled(component, process.platform), `${component} has its documented enablement default`) const resolve = component === 'discovery' ? resolveDiscoveryConfig : component === 'tui' ? resolveTuiConfig - : component === 'ptc' ? resolvePtcConfig - : component === 'hide-official' ? resolveHidingConfig - : component === 'panel' ? resolvePanelConfig - : resolveConfig + : component === 'compat' ? (() => ({})) + : component === 'panel' ? resolvePanelConfig + : resolveModeConfig try { resolve(row.config); check(true, `${component} config is valid`) for (const fact of defaultChecks(component, row.config, resolve)) check(...fact) @@ -319,10 +320,11 @@ for (const [index, entry] of namespaces.slice(1).entries()) { const sections = manifestRaw.contributes?.['x-prompt-sections'] ?? [] const sectionNames = sections.map((section) => section.name) +const expectedSections = Object.keys(MODE_SECTION_ORDERS).map(suffix => `${TOOLING_SECTION}:${suffix}`) check( - sectionNames.length === 3 && ['file', 'shell', 'ptc'].every(component => sectionNames.includes(`${TOOLING_SECTION}:${component}`)), + sectionNames.length === expectedSections.length && expectedSections.every(name => sectionNames.includes(name)), 'the declared prompt sections match lib/policy.js', - `declared ${sectionNames.join(', ')} vs policy ${TOOLING_SECTION}`, + `declared ${sectionNames.join(', ')} vs policy ${expectedSections.join(', ')}`, ) check( sections.every((section) => typeof section.order === 'number' && Number.isFinite(section.order)), @@ -494,10 +496,10 @@ if (display !== undefined) { } /** Payload the published package must carry, as `files` entries or paths they cover. */ -const ptc = manifestRaw.contributes?.['x-components']?.find(c => c.id === 'dsh-ops-ptc') +const ptc = manifestRaw.contributes?.['x-components']?.find(c => c.id === 'dsh-ops-mode-ptc') const components = manifestRaw.contributes?.['x-components'] ?? [] for (const fact of componentChecks(components)) check(...fact) -check(ptc?.entry === 'dsh-ops/ptc' && ptc?.defaultEnabled === true, 'native PTC enhancement is enabled by default; presentation stays scoped') +check(ptc?.entry === 'dsh-ops/mode-ptc' && ptc?.defaultEnabled === true, 'native PTC enhancement is enabled by default; presentation stays scoped') check(JSON.stringify(ptc?.tools) === JSON.stringify([...PTC_API_TOOLS, 'load_sdk'].map(publicToolName)), 'PTC declaration matches the generated tool contract') check(PTC_API_TOOLS.every(t => definition(t, () => {}).parameters.type === 'object'), 'PTC schemas remain structured') check(JSON.stringify(pkg.os) === '["win32","linux","darwin"]' && JSON.stringify(pkg.cpu) === '["x64","arm64"]' && @@ -506,10 +508,10 @@ check(JSON.stringify(pkg.os) === '["win32","linux","darwin"]' && JSON.stringify( 'Supported exact targets are Windows x64, Linux x64 and macOS arm64/x64; Intel Mac is supported') /** Every component states the platforms it really serves, so a Linux claim cannot ride along. */ const platformClaims = id => manifestRaw.contributes?.['x-components']?.find(c => c.id === id)?.platforms -check(JSON.stringify(platformClaims('dsh-ops-shell')) === '["win32","linux","darwin"]', - 'the shell component supports the declared targets using host bash on POSIX') -check(JSON.stringify(platformClaims('dsh-ops-file')) === '["win32","linux","darwin"]', - 'the file component supports the exact worker target matrix') +check(JSON.stringify(platformClaims('dsh-ops-mode-standard')) === '["win32","linux","darwin"]', + 'the standard mode supports the declared targets using host bash on POSIX') +check(JSON.stringify(platformClaims('dsh-ops-mode-ptc')) === '["win32","linux","darwin"]', + 'the PTC mode supports the exact worker target matrix') check(platformClaims('dsh-ops-background') === undefined, 'the removed background component is no longer declared') check(JSON.stringify(platformClaims('dsh-ops-panel')) === '["win32","linux","darwin"]', diff --git a/src/cli/bash-runtime.ts b/src/cli/bash-runtime.ts index 53cae003..60a801d5 100644 --- a/src/cli/bash-runtime.ts +++ b/src/cli/bash-runtime.ts @@ -16,7 +16,7 @@ export function commandBashRuntime(action = 'status', options: BashRuntimeComman managed: managedBashInventory(), git: gitBashCandidates() } else throw new Error('Expected bash-runtime status, install, remove, git or managed.') console.log(JSON.stringify(result, null, 2)) - if (action === 'git' || action === 'managed') console.log('Apply the config object to dsh-ops/shell with the host config editor, then reload the component. No profile was changed.') + if (action === 'git' || action === 'managed') console.log('Apply the config object to the matching dsh-ops mode row with the host config editor, then reload the component. No profile was changed.') if (action === 'remove' && 'action' in result && result.action === 'preview') console.log('Pass --yes to remove only these managed Bash versions. Stop running Bash jobs before removal.') return 0 } diff --git a/src/cli/settings-profile.ts b/src/cli/settings-profile.ts index 1d73a987..1baa32ec 100644 --- a/src/cli/settings-profile.ts +++ b/src/cli/settings-profile.ts @@ -20,12 +20,12 @@ export async function commandSettingsProfile(options: { profileDir?: string; hos if (!options.profileDir || !options.hostAnchor) throw new Error('Use --profile-dir and --host-anchor .') const host = await loadCarrierHost(options.hostAnchor) const plan = await carrierPreview(host, { dir: options.profileDir, installAnchor: options.hostAnchor, home: options.home ?? dshHome() }) - console.log(`Select ${CLIENT_BUNDLE} instead of dsh-ops; retain seven row IDs, switches and effective configuration.`) - console.log(`Normalize ${plan.changedQualifiers} verified file-row name matcher(s). Files: ${plan.files.map(file => file.path).join(', ')}`) + console.log(`Select ${CLIENT_BUNDLE} instead of dsh-ops; retain the dsh-ops component rows, switches and effective configuration.`) + console.log(`Files: ${plan.files.map(file => file.path).join(', ')}`) console.log('Stop all DSH processes using this profile before applying. Launch-time command-line overlays are outside this persistent profile preview.') if (!options.yes || options.dryRun) { console.log('Preview only. Apply with --offline --yes after stopping DSH.'); return 0 } if (!options.offline) throw new Error('--offline is required to confirm the profile has been stopped.') const backup = await applyCarrier(host, plan) - console.log(`Saved and verified. Backup: ${backup}. Restart DSH; custom pages require the file row to remain enabled.`) + console.log(`Saved and verified. Backup: ${backup}. Restart DSH; custom pages require the client carrier bundle and an enabled target row.`) return 0 } diff --git a/src/client/index.tsx b/src/client/index.tsx index 197ef93b..57b6c44f 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -4,7 +4,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-api-gateway/client' import { RUNTIME_REMOTE } from '../settings/runtime-contract.js' import { en, zh } from './locales.js' -import { ReplacementForm, DiscoveryForm } from './selections.js' +import { DiscoveryForm } from './selections.js' import { ShellForm } from './shell-form.js' import type { SettingsProps } from './forms.js' import { SETTINGS_CSS } from './styles.js' @@ -18,12 +18,17 @@ export async function apply(ctx: Context): Promise { style.dataset.dshOpsSettings = 'true'; style.textContent = SETTINGS_CSS; document.head.append(style) return () => style.remove() }, 'dsh-ops: settings styles') + // Every enhanced-preset row shares the shell form: a mode row owns bashPath and + // publishBashTool, and the host schema already edits the PTC pool of mode-ptc. ctx.slots.inject('plugins.row.config', function* () { - yield ctx.slots.register({ name: 'plugins.row.config', key: 'dsh-ops#dsh-ops-hide-official', locale: 'dshOps' }, - (props: SettingsProps) => props.view === 'summary' ? <>{props.t('replacements')} : ) + for (const [row, summary] of [ + ['dsh-ops-mode-minimal', 'modeMinimal'], ['dsh-ops-mode-standard', 'modeStandard'], + ['dsh-ops-mode-ptc', 'modePtc'], ['dsh-ops-mode-cordis', 'modeCordis'], + ] as const) { + yield ctx.slots.register({ name: 'plugins.row.config', key: `dsh-ops#${row}`, locale: 'dshOps' }, + (props: SettingsProps) => props.view === 'summary' ? <>{props.t(summary)} : ) + } yield ctx.slots.register({ name: 'plugins.row.config', key: 'dsh-ops#dsh-ops-discovery', locale: 'dshOps' }, (props: SettingsProps) => props.view === 'summary' ? <>{props.t('discovery')} : ) - yield ctx.slots.register({ name: 'plugins.row.config', key: 'dsh-ops#dsh-ops-shell', locale: 'dshOps' }, - (props: SettingsProps) => props.view === 'summary' ? <>{props.t('shell')} : ) }) } diff --git a/src/client/locales.ts b/src/client/locales.ts index 85aeb6dd..b4513839 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -1,6 +1,8 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' export const en = { replacements: 'Choose which official tools OPS replaces.', discovery: 'Choose discoverable and always-loaded capabilities.', shell: 'Manage Bash and the standard host PowerShell installation.', + modeMinimal: 'Preset enhancement: adds OPS tools to Minimal sessions and replaces the official Windows shell.', modeStandard: 'Preset enhancement: replaces the official read, glob and grep tools and the Windows shell for Standard sessions.', + modePtc: 'Preset enhancement: publishes the PTC SDK for PTC sessions and replaces the official Windows shell.', modeCordis: 'Preset enhancement: replaces the official read, glob and grep tools and the Windows shell for Cordis sessions.', unavailable: 'Enable this row with the built-in switch to edit its settings. A writable host profile is required.', loading: 'Loading host settings…', save: 'Save changes', reset: 'Reset to saved', saved: 'Saved. The host reloaded this row.', rejected: 'The host refused this save. Reset to the latest saved values and review your changes.', saving: 'Saving…', replacementsHelp: 'Selected tools use OPS argument and output contracts. Read is bounded UTF-8; search excludes hidden files and links. Leave a tool unchecked to keep the official workflow.', @@ -15,11 +17,13 @@ export const en = { install: 'Install managed Bash', remove: 'Remove managed Bash', upgrade: 'Install or upgrade PowerShell', preview: 'Review this action', cancel: 'Cancel running action', dismiss: 'Close preview', busy: 'Working…', pwshHelp: 'This updates the standard PowerShell 7 installation through WinGet. Restart DSH afterwards. Review a custom host pwshPath separately; Windows PowerShell 5.1 is preserved.', - unknownEffect: 'Changes may have occurred. Inspect the installation before trying again.', noRuntime: 'Runtime controls require an enabled shell row and the host runtime services.', + unknownEffect: 'Changes may have occurred. Inspect the installation before trying again.', noRuntime: 'Runtime controls require an enabled mode row and the host runtime services.', exitCode: 'Exit code', managedCount: 'Managed installations', gitCount: 'Git Bash installations', error: 'The operation failed.', } export const zh: Record = { replacements: '选择由 OPS 替换的官方工具。', discovery: '选择可发现及始终加载的能力。', shell: '管理 Bash 和标准宿主 PowerShell 安装。', + modeMinimal: '模式增强:为 Minimal 会话提供 OPS 工具并替换官方 Windows shell。', modeStandard: '模式增强:为 Standard 会话替换官方 read、glob、grep 与 Windows shell。', + modePtc: '模式增强:为 PTC 会话提供 PTC SDK 并替换官方 Windows shell。', modeCordis: '模式增强:为 Cordis 会话替换官方 read、glob、grep 与 Windows shell。', unavailable: '请先用内置开关启用此行。编辑设置需要可写的宿主配置。', loading: '正在加载宿主设置…', save: '保存更改', reset: '恢复已保存值', saved: '已保存,宿主已重新加载此行。', rejected: '宿主拒绝了此次保存。请恢复最新已保存值后重新检查更改。', saving: '正在保存…', replacementsHelp: '所选工具改用 OPS 参数和输出契约。读取仅支持有界 UTF-8;搜索不包含隐藏文件和链接。取消勾选即可保留官方流程。', @@ -34,7 +38,7 @@ export const zh: Record = { install: '安装受管理的 Bash', remove: '删除受管理的 Bash', upgrade: '安装或升级 PowerShell', preview: '检查本次操作', cancel: '取消正在执行的操作', dismiss: '关闭预览', busy: '正在处理…', pwshHelp: '通过 WinGet 更新标准 PowerShell 7 安装,完成后需重启 DSH。宿主自定义 pwshPath 需要单独检查;Windows PowerShell 5.1 将保留。', - unknownEffect: '可能已经发生更改。再次尝试前,请检查安装状态。', noRuntime: '运行时控制需要启用 shell 行并提供宿主运行时服务。', + unknownEffect: '可能已经发生更改。再次尝试前,请检查安装状态。', noRuntime: '运行时控制需要启用模式行并提供宿主运行时服务。', exitCode: '退出码', managedCount: '受管理的安装', gitCount: 'Git Bash 安装', error: '操作失败。', } declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { dshOps: keyof typeof en } } diff --git a/src/components/compat/icon.png b/src/components/compat/icon.png new file mode 100644 index 00000000..cb35b43e Binary files /dev/null and b/src/components/compat/icon.png differ diff --git a/src/components/compat/index.ts b/src/components/compat/index.ts new file mode 100644 index 00000000..f3bb2512 --- /dev/null +++ b/src/components/compat/index.ts @@ -0,0 +1,9 @@ +/** Compatibility switch: keep the official tools exposed instead of replaced. */ +import type { Context } from '@deepseek-ai/cordis' +import { joinCompatibility } from '../../modes/registry.js' +export const name = 'dsh-ops-compat' +export function apply(ctx: Context): void { + const leave = joinCompatibility(ctx, true) + ctx.effect(() => () => leave(), 'dsh-ops: compatibility switch') + ctx.logger.info('dsh-ops: Compatibility mode is on; official tools stay exposed. Token efficiency and model ability are reduced.') +} diff --git a/src/components/compat/locale/en.json b/src/components/compat/locale/en.json new file mode 100644 index 00000000..19ec81f6 --- /dev/null +++ b/src/components/compat/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Compatibility mode", + "description": "Turn off OPS overrides of the built-in tools. This affects token efficiency and model capability; use it only as a safety fallback when DSH cannot work properly with OPS enabled." + } +} diff --git a/src/components/compat/locale/zh.json b/src/components/compat/locale/zh.json new file mode 100644 index 00000000..169c34cf --- /dev/null +++ b/src/components/compat/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "兼容模式", + "description": "取消ops对原版工具的覆盖。该项会影响token效率和智力,仅作为假如ops启用后dsh无法正常使用的安全备选措施。" + } +} diff --git a/src/components/compat/package.json b/src/components/compat/package.json new file mode 100644 index 00000000..45d801b4 --- /dev/null +++ b/src/components/compat/package.json @@ -0,0 +1,6 @@ +{ + "name": "dsh-ops-compat", + "description": "Keep the official tools exposed instead of the OPS overrides; a safety fallback that costs token efficiency and model capability.", + "icon": "./icon.png", + "type": "module" +} diff --git a/src/components/defaults.ts b/src/components/defaults.ts index 03a0dc02..d1c98946 100644 --- a/src/components/defaults.ts +++ b/src/components/defaults.ts @@ -1,9 +1,9 @@ /** Package defaults only. Saved Loader overrides take precedence over this layer. */ -export const COMPONENTS = ['shell', 'file', 'ptc', 'discovery', 'panel', 'tui', 'hide-official'] as const +export const COMPONENTS = ['mode-minimal', 'mode-standard', 'mode-ptc', 'mode-cordis', 'discovery', 'compat', 'panel', 'tui'] as const export type Component = typeof COMPONENTS[number] export function componentEnabled(component: Component, _platform: string): boolean { - // The shell row is enabled on every supported platform: on POSIX it resolves the - // host's bash, on Windows the pinned optional payload. - return ['shell', 'file', 'ptc', 'discovery'].includes(component) + // Enhanced presets and progressive discovery start enabled; the official-tool + // fallback, data capture, and the TUI integration are opt-in. + return !['compat', 'panel', 'tui'].includes(component) } export function componentEntry(component: Component): string { return `dsh-ops/${component}` } diff --git a/src/components/discovery/locale/en.json b/src/components/discovery/locale/en.json index cf67c049..ee4b52ca 100644 --- a/src/components/discovery/locale/en.json +++ b/src/components/discovery/locale/en.json @@ -1,6 +1,6 @@ { "meta": { - "title": "Advanced tools on demand", - "description": "Discover allowed advanced file tools for the next model step. Always-loaded choices remain available when the discovery entry is disabled." + "title": "Enable dynamic tool loading", + "description": "Provide advanced tools that do not occupy the system prompt and let the AI discover them on demand. Recommended only with the official deepseek-flash model." } } diff --git a/src/components/discovery/locale/zh.json b/src/components/discovery/locale/zh.json index b9226855..f2710d80 100644 --- a/src/components/discovery/locale/zh.json +++ b/src/components/discovery/locale/zh.json @@ -1,6 +1,6 @@ { "meta": { - "title": "按需高级工具", - "description": "按需发现允许的高级文件工具,下一模型步骤可用。关闭发现入口时,始终加载的选择仍可用。" + "title": "启用动态工具加载", + "description": "提供一系列不占用系统提示词而是让AI自主发现的高级工具。仅建议使用官方deepseek-flash时开启。" } } diff --git a/src/components/discovery/package.json b/src/components/discovery/package.json index 95eeea65..95b5ca52 100644 --- a/src/components/discovery/package.json +++ b/src/components/discovery/package.json @@ -1,6 +1,6 @@ { "name": "dsh-ops-discovery", - "description": "Scope-owned progressive discovery of reviewed advanced OPS tools.", + "description": "Provide advanced tools that do not occupy the system prompt and let the AI discover them on demand.", "icon": "./icon.png", "type": "module" } diff --git a/src/components/mode-cordis/icon.png b/src/components/mode-cordis/icon.png new file mode 100644 index 00000000..cb35b43e Binary files /dev/null and b/src/components/mode-cordis/icon.png differ diff --git a/src/components/mode-cordis/index.ts b/src/components/mode-cordis/index.ts new file mode 100644 index 00000000..235db021 --- /dev/null +++ b/src/components/mode-cordis/index.ts @@ -0,0 +1,7 @@ +/** One enhanced built-in preset. The row publishes OPS only for sessions of that preset. */ +import type { Context } from '@deepseek-ai/cordis' +import { applyMode } from '../../modes/apply.js' +import { modeConfigForm } from '../../modes/config.js' +export const name = 'dsh-ops-mode-cordis' +export const Config = modeConfigForm('cordis') +export function apply(ctx: Context, config: unknown): void { applyMode(ctx, config, 'cordis') } diff --git a/src/components/mode-cordis/locale/en.json b/src/components/mode-cordis/locale/en.json new file mode 100644 index 00000000..b39d2f48 --- /dev/null +++ b/src/components/mode-cordis/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Optimize the built-in Cordis preset", + "description": "Add useful OPS tools to the built-in Cordis preset and replace some built-in tools. Replaces pwsh with bash when running on Windows." + } +} diff --git a/src/components/mode-cordis/locale/zh.json b/src/components/mode-cordis/locale/zh.json new file mode 100644 index 00000000..74a6a437 --- /dev/null +++ b/src/components/mode-cordis/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "优化原版创造模式", + "description": "为原版创造模式增加一些好用的ops工具并替换部分原版工具。且若当前环境为windows,将pwsh替换为bash。" + } +} diff --git a/src/components/mode-cordis/package.json b/src/components/mode-cordis/package.json new file mode 100644 index 00000000..b4d4998f --- /dev/null +++ b/src/components/mode-cordis/package.json @@ -0,0 +1,6 @@ +{ + "name": "dsh-ops-mode-cordis", + "description": "Add useful OPS tools to the built-in Cordis preset, replace some built-in tools, and use bash instead of pwsh on Windows.", + "icon": "./icon.png", + "type": "module" +} diff --git a/src/components/mode-minimal/icon.png b/src/components/mode-minimal/icon.png new file mode 100644 index 00000000..cb35b43e Binary files /dev/null and b/src/components/mode-minimal/icon.png differ diff --git a/src/components/mode-minimal/index.ts b/src/components/mode-minimal/index.ts new file mode 100644 index 00000000..755d069c --- /dev/null +++ b/src/components/mode-minimal/index.ts @@ -0,0 +1,7 @@ +/** One enhanced built-in preset. The row publishes OPS only for sessions of that preset. */ +import type { Context } from '@deepseek-ai/cordis' +import { applyMode } from '../../modes/apply.js' +import { modeConfigForm } from '../../modes/config.js' +export const name = 'dsh-ops-mode-minimal' +export const Config = modeConfigForm('minimal') +export function apply(ctx: Context, config: unknown): void { applyMode(ctx, config, 'minimal') } diff --git a/src/components/mode-minimal/locale/en.json b/src/components/mode-minimal/locale/en.json new file mode 100644 index 00000000..68f9a332 --- /dev/null +++ b/src/components/mode-minimal/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Optimize the built-in Minimal preset", + "description": "Start dynamic tool loading while keeping the Minimal preset's terse prompt style (enable the dynamic tool loading option as well). Replaces pwsh with bash when running on Windows." + } +} diff --git a/src/components/mode-minimal/locale/zh.json b/src/components/mode-minimal/locale/zh.json new file mode 100644 index 00000000..312109ea --- /dev/null +++ b/src/components/mode-minimal/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "优化原版极简模式", + "description": "在保留极简模式极简提示词的风格下启动动态工具加载(需要启用动态工具加载选项同时开启)。且若当前环境为windows,将pwsh替换为bash。" + } +} diff --git a/src/components/mode-minimal/package.json b/src/components/mode-minimal/package.json new file mode 100644 index 00000000..c57eef88 --- /dev/null +++ b/src/components/mode-minimal/package.json @@ -0,0 +1,6 @@ +{ + "name": "dsh-ops-mode-minimal", + "description": "Start dynamic tool loading without loosening the Minimal preset's terse prompts, and replace pwsh with bash on Windows.", + "icon": "./icon.png", + "type": "module" +} diff --git a/src/components/mode-ptc/icon.png b/src/components/mode-ptc/icon.png new file mode 100644 index 00000000..cb35b43e Binary files /dev/null and b/src/components/mode-ptc/icon.png differ diff --git a/src/components/mode-ptc/index.ts b/src/components/mode-ptc/index.ts new file mode 100644 index 00000000..01d72a6a --- /dev/null +++ b/src/components/mode-ptc/index.ts @@ -0,0 +1,7 @@ +/** One enhanced built-in preset. The row publishes OPS only for sessions of that preset. */ +import type { Context } from '@deepseek-ai/cordis' +import { applyMode } from '../../modes/apply.js' +import { modeConfigForm } from '../../modes/config.js' +export const name = 'dsh-ops-mode-ptc' +export const Config = modeConfigForm('ptc') +export function apply(ctx: Context, config: unknown): void { applyMode(ctx, config, 'ptc') } diff --git a/src/components/mode-ptc/locale/en.json b/src/components/mode-ptc/locale/en.json new file mode 100644 index 00000000..21868eb3 --- /dev/null +++ b/src/components/mode-ptc/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Optimize the built-in PTC preset", + "description": "Add useful OPS tools to the built-in PTC preset and replace some built-in tools. Replaces pwsh with bash when running on Windows." + } +} diff --git a/src/components/mode-ptc/locale/zh.json b/src/components/mode-ptc/locale/zh.json new file mode 100644 index 00000000..b2684333 --- /dev/null +++ b/src/components/mode-ptc/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "优化原版ptc模式", + "description": "为原版ptc模式增加一些好用的ops工具并替换部分原版工具。且若当前环境为windows,将pwsh替换为bash。" + } +} diff --git a/src/components/mode-ptc/package.json b/src/components/mode-ptc/package.json new file mode 100644 index 00000000..af5cc06e --- /dev/null +++ b/src/components/mode-ptc/package.json @@ -0,0 +1,6 @@ +{ + "name": "dsh-ops-mode-ptc", + "description": "Add useful OPS tools to the built-in PTC preset, replace some built-in tools, and use bash instead of pwsh on Windows.", + "icon": "./icon.png", + "type": "module" +} diff --git a/src/components/mode-standard/icon.png b/src/components/mode-standard/icon.png new file mode 100644 index 00000000..cb35b43e Binary files /dev/null and b/src/components/mode-standard/icon.png differ diff --git a/src/components/mode-standard/index.ts b/src/components/mode-standard/index.ts new file mode 100644 index 00000000..b0321024 --- /dev/null +++ b/src/components/mode-standard/index.ts @@ -0,0 +1,7 @@ +/** One enhanced built-in preset. The row publishes OPS only for sessions of that preset. */ +import type { Context } from '@deepseek-ai/cordis' +import { applyMode } from '../../modes/apply.js' +import { modeConfigForm } from '../../modes/config.js' +export const name = 'dsh-ops-mode-standard' +export const Config = modeConfigForm('standard') +export function apply(ctx: Context, config: unknown): void { applyMode(ctx, config, 'standard') } diff --git a/src/components/mode-standard/locale/en.json b/src/components/mode-standard/locale/en.json new file mode 100644 index 00000000..8ca8ef7b --- /dev/null +++ b/src/components/mode-standard/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Optimize the built-in Standard preset", + "description": "Add useful OPS tools to the built-in Standard preset and replace some built-in tools. Replaces pwsh with bash when running on Windows." + } +} diff --git a/src/components/mode-standard/locale/zh.json b/src/components/mode-standard/locale/zh.json new file mode 100644 index 00000000..cf328759 --- /dev/null +++ b/src/components/mode-standard/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "优化原版标准模式", + "description": "为原版标准模式增加一些好用的ops工具并替换部分原版工具。且若当前环境为windows,将pwsh替换为bash。" + } +} diff --git a/src/components/mode-standard/package.json b/src/components/mode-standard/package.json new file mode 100644 index 00000000..811e2b13 --- /dev/null +++ b/src/components/mode-standard/package.json @@ -0,0 +1,6 @@ +{ + "name": "dsh-ops-mode-standard", + "description": "Add useful OPS tools to the built-in Standard preset, replace some built-in tools, and use bash instead of pwsh on Windows.", + "icon": "./icon.png", + "type": "module" +} diff --git a/src/components/panel/locale/en.json b/src/components/panel/locale/en.json index e7496cfb..68bebba5 100644 --- a/src/components/panel/locale/en.json +++ b/src/components/panel/locale/en.json @@ -1,6 +1,6 @@ { "meta": { - "title": "Panel data capture", - "description": "Off by default. Once switched on, new conversations write /.dsh-ops/panel/.json (the only data source) plus a panel.html viewer that reads it. Static files only: no UI panel, no port, no ~/.dsh writes." + "title": "Data inspection", + "description": "Record OPS logs and reports for developer diagnosis. Turning it on or off does not affect daily use." } } diff --git a/src/components/panel/locale/zh.json b/src/components/panel/locale/zh.json index b4bfbe61..715b21a0 100644 --- a/src/components/panel/locale/zh.json +++ b/src/components/panel/locale/zh.json @@ -1,6 +1,6 @@ { "meta": { - "title": "启动数据检测", - "description": "默认关闭。打开后,新对话会在该会话的工作目录内生成 .dsh-ops/panel/.json(唯一数据源)与读取它的 panel.html 查看器。只写静态文件:不注册界面、不开端口、不写 ~/.dsh,默认关闭时零开销。" + "title": "数据检测", + "description": "记录ops的日志和报告以供开发者诊断。是否开启不影响日常使用。" } } diff --git a/src/components/panel/package.json b/src/components/panel/package.json index 6d85fc33..91c2a5f7 100644 --- a/src/components/panel/package.json +++ b/src/components/panel/package.json @@ -1,7 +1,7 @@ { "name": "dsh-ops-panel", "version": "0.4.1", - "description": "Opt-in capture of new conversations into /.dsh-ops/panel: one JSON data document per session plus a static viewer that reads it.", + "description": "Record OPS logs and reports for developer diagnosis. Turning it on or off does not affect daily use.", "icon": "./icon.png", "type": "module" } diff --git a/src/components/tui/locale/en.json b/src/components/tui/locale/en.json index d8436da9..55e031c9 100644 --- a/src/components/tui/locale/en.json +++ b/src/components/tui/locale/en.json @@ -1 +1 @@ -{"meta":{"title":"OPS sidebar","description":"Read-only OPS activity for the current dsh-TUI session; no commands or extra model calls."}} +{"meta":{"title":"TUI-OPS integration","description":"Plugin integration item. When both dsh-tui and dsh-ops are installed, the OPS sidebar can be shown in dsh-tui."}} diff --git a/src/components/tui/locale/zh.json b/src/components/tui/locale/zh.json index 06f5cc8a..bc89ee75 100644 --- a/src/components/tui/locale/zh.json +++ b/src/components/tui/locale/zh.json @@ -1 +1 @@ -{"meta":{"title":"OPS 侧栏","description":"当前 dsh-TUI 会话的 OPS 只读状态,不执行命令或额外调用模型。"}} +{"meta":{"title":"TUI-OPS联动","description":"插件联动项。同时安装dsh-tui与dsh-ops时,可以在dsh-tui显示ops侧栏"}} diff --git a/src/components/tui/package.json b/src/components/tui/package.json index cb8be3c1..7c754825 100644 --- a/src/components/tui/package.json +++ b/src/components/tui/package.json @@ -1,7 +1,7 @@ { "name": "dsh-ops-tui", "version": "0.4.1", - "description": "Optional read-only OPS activity sidebar for dsh-TUI.", + "description": "Plugin integration item. When both dsh-tui and dsh-ops are installed, the OPS sidebar can be shown in dsh-tui.", "icon": "./icon.png", "type": "module" } diff --git a/src/discovery/index.ts b/src/discovery/index.ts index 6e079c65..6bf67774 100644 --- a/src/discovery/index.ts +++ b/src/discovery/index.ts @@ -18,6 +18,9 @@ import { DISCOVERABLE_TOOLS } from './config.js' import type { DiscoverableName } from './config.js' import { parseRequest, search } from './search.js' import { scopedReconciliation, scopeFamilies } from '../scoped-reconciliation.js' +import { modeOf } from '../modes/detect.js' +import { residentTools } from '../modes/plan.js' +import { enabledModes, onModesChange } from '../modes/registry.js' interface Entry { ctx?: Context @@ -61,6 +64,16 @@ export function apply(root: Context, raw: unknown): void { try { dispose() } catch { ctx.logger.warn('dsh-ops: discovery registration cleanup failed.') } } } + // Dynamic loading is its own switch: it serves every native session, independent + // of the mode rows. Only capabilities a running mode already publishes resident + // are excluded from the discovery pool. + const residentSet = (agent: Agent): ReadonlySet => { + const mode = modeOf(ctx, agent) + // A mode row that is off publishes nothing resident, so its names stay in the + // discovery pool even though the preset matches. + if (mode === undefined || !enabledModes(ctx).has(mode)) return new Set() + return new Set(residentTools(mode)) + } const permitted = (agent: Agent, rawName?: DiscoverableName): boolean => { try { const policy = policyService.resolve({ session: agent.session }) @@ -68,6 +81,9 @@ export function apply(root: Context, raw: unknown): void { && (!rawName || rawName === 'result' || !capability(rawName)?.mutates || policy.mode !== 'read-only') } catch { return false } } + // A tool the mode already publishes resident is not a discovery choice. + const selectable = (agent: Agent, rawName: DiscoverableName): boolean => + !residentSet(agent).has(publicToolName(rawName)) && permitted(agent, rawName) const ptcOnly = (assembly: PromptAssembly): boolean => assembly.sections.some(section => section.name === 'tools:ptc-only' && Boolean(section.text.trim())) const invalidate = (entry: Entry) => { ++entry.revision; entry.presentation = undefined } const inputs = (agent: Agent): unknown[] => { @@ -106,8 +122,17 @@ export function apply(root: Context, raw: unknown): void { entry.disposers.push(dispose) entry.owned.add(tool.name) } - const inheritedNative = (agent: Agent, entry: Entry): string[] => nativeProviders(ctx, agent) - .map(provider => provider.definition.name).filter(name => (advanced.includes(name) || name === DISCOVER) && !entry.owned.has(name)) + // Resident mode tools are published by the mode row itself; they are neither + // discovery choices nor inherited duplicates to hide. + const foreignAdvanced = (agent: Agent): string[] => { + const resident = residentSet(agent) + return advanced.filter(name => !resident.has(name)) + } + const inheritedNative = (agent: Agent, entry: Entry): string[] => { + const foreign = new Set(foreignAdvanced(agent)) + return nativeProviders(ctx, agent).map(provider => provider.definition.name) + .filter(name => (foreign.has(name) || name === DISCOVER) && !entry.owned.has(name)) + } const reconcile = (targets: Iterable = entries.keys()): Promise => { if (stopped) return Promise.resolve() const families = scopeFamilies(entries.keys(), targets, scopeChainOf) @@ -124,9 +149,9 @@ export function apply(root: Context, raw: unknown): void { for (const [agent, entry] of entries) { if (!family.has(agent)) continue if (!ownership?.owns(agent)) { clear(entry); entry.releaseSelection?.(); entry.releaseSelection = undefined; continue } - entry.releaseSelection ??= claimNativeSelection(ctx, agent, new Set(advanced)) + entry.releaseSelection ??= claimNativeSelection(ctx, agent, new Set(foreignAdvanced(agent))) const isReady = ready.has(agent) - const wanted = isReady ? [...new Set([...config.alwaysLoaded, ...entry.selected])].filter(tool => permitted(agent, tool)) : [] + const wanted = isReady ? [...new Set([...config.alwaysLoaded, ...entry.selected])].filter(tool => selectable(agent, tool)) : [] const desired = new Set([...(isReady && config.enabled ? [DISCOVER] : []), ...wanted.map(publicToolName)]) const inherited = inheritedNative(agent, entry) // Registry changes from other components must not republish an unchanged @@ -139,7 +164,7 @@ export function apply(root: Context, raw: unknown): void { execute: async args => { const request = parseRequest(args) if (typeof request === 'string') return failure('INVALID_ARGUMENT', request) - const matches = search(request.query, config.allowedTools.filter(tool => permitted(agent, tool)), Math.min(request.maxResults, config.maxResults)) + const matches = search(request.query, config.allowedTools.filter(tool => selectable(agent, tool)), Math.min(request.maxResults, config.maxResults)) const activated = matches.filter(tool => !entry.selected.has(tool) && !config.alwaysLoaded.includes(tool)) for (const tool of activated) entry.pending.add(tool) return { status: 'completed', activated: activated.map(publicToolName), @@ -216,6 +241,9 @@ export function apply(root: Context, raw: unknown): void { void reconcile(affected) }) ctx.on('system-prompt/change', refresh) + // A mode row toggling changes which capabilities stay resident and therefore + // must leave the discovery pool. + ctx.effect(() => onModesChange(ctx, refresh), 'dsh-ops: discovery mode observer') // Observe in-place hooks and the default asynchronous Fiber.restart(). // Await the runtime result even though the pinned next() type says void. ctx.on('internal/update', async (_config, _noSave, next) => { diff --git a/src/index.ts b/src/index.ts index 1964fbb6..eabf76ec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import { visibleNames } from './surface.js' import { mountWorkspacePreparation } from './workspace-preparation.js' import { safeDispose, safeDisposeAll } from './teardown.js' import { mountResultLifecycle } from './kernel/result-lifecycle.js' +import { applyMode } from './modes/apply.js' export { Config } from './settings/file-config.js' export const name = 'dsh-ops' @@ -136,5 +137,7 @@ export function applyComponent(root: Context, rawConfig: unknown, component: Com } } -// Backward-compatible module entry. The bundle uses independently toggled rows. -export function apply(root: Context, rawConfig: unknown): void { return applyComponent(root, rawConfig, 'file') } +// Package-root entry. The optional `dsh-ops/lib/profiles/client` carrier row names +// this module instead of `dsh-ops/mode-standard`, so it must publish exactly that +// mode; the canonical bundle inserts the explicit component rows instead. +export function apply(root: Context, rawConfig: unknown): void { return applyMode(root, rawConfig, 'standard') } diff --git a/src/modes/apply.ts b/src/modes/apply.ts new file mode 100644 index 00000000..85d13d10 --- /dev/null +++ b/src/modes/apply.ts @@ -0,0 +1,55 @@ +/** + * Apply one enhanced-preset mode row. + * + * Each row is self-contained: it registers its mode in the shared registry, then + * publishes only for Agents composed from its preset. The native file surface is + * registered per Agent, the Windows shell layer and the PTC SDK are gated by the + * same preset test, and the replacement controller hides only official tools + * whose OPS counterpart is visible. Compatibility mode is a runtime switch that + * lifts those hides without unloading any row. + */ +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { apply as mountPtc } from '../ptc/index.js' +import { mountSessionBash, type BashGate } from '../session-shells.js' +import { resolveShells } from '../shells.js' +import { mountWorkspacePreparation } from '../workspace-preparation.js' +import { resolveModeConfig, type ModeConfig } from './config.js' +import { modeOf } from './detect.js' +import { mountNativeMode } from './native.js' +import { MODE_PLANS } from './plan.js' +import { joinMode, onModesChange } from './registry.js' +import { mountReplacements } from './replacements.js' +import type { ModeId } from './types.js' + +/** + * Mount one enhanced preset mode. + * @param root - the mode row's context. + * @param rawConfig - the loader row's config value. + * @param mode - the mode the row enables. + * @returns void; every registration is disposed with the row. + */ +export function applyMode(root: Context, rawConfig: unknown, mode: ModeId): void { + const config: ModeConfig = resolveModeConfig(rawConfig) + const plan = MODE_PLANS[mode] + const leave = joinMode(root, mode, config) + root.effect(() => () => leave(), `dsh-ops: ${mode} mode switch`) + const matches = (agent: Agent): boolean => modeOf(root, agent) === mode + const watch = (notify: () => void): (() => void) => onModesChange(root, notify) + // The Windows shell replacement is the only shell work a mode row performs: + // POSIX sessions already run the host's bash, so OPS publishes nothing there. + if (plan.shell && process.platform === 'win32') { + mountWorkspacePreparation(root) + const gate: BashGate = { allows: matches, watch } + const shells = resolveShells(config) + root.inject(['tools'], ctx => { mountSessionBash(ctx, config, shells, gate) }) + } + mountReplacements(root, config, mode) + if (plan.nativeFiles) mountNativeMode(root, config, mode) + if (plan.ptc) { + mountPtc(root, { + binaryPath: config.binaryPath, required: config.required, + allowedTools: config.allowedTools, alwaysLoaded: config.alwaysLoaded, + }, { allows: matches, watch }) + } +} diff --git a/src/modes/config.ts b/src/modes/config.ts new file mode 100644 index 00000000..0d096e68 --- /dev/null +++ b/src/modes/config.ts @@ -0,0 +1,94 @@ +/** + * Configuration for a mode row. + * + * A mode row carries the file/PTC resolver settings it needs and accepts the + * compatibility keys older rows used, so one validator owns every accepted key + * and a malformed row still fails loud and names the offending key. + */ +import { LEGACY_FASTCTX_KEYS, resolveConfig, type ResolvedConfig } from '../config.js' +import { parsePtcConfig, type PtcConfig } from '../ptc/config.js' +import { editable, settingsConfig, type Field } from '../settings/schema.js' +import { configObject } from '../settings/schema.js' +import type { ModeId } from './types.js' + +/** One mode row's resolved configuration. */ +export interface ModeConfig extends ResolvedConfig { + allowedTools: PtcConfig['allowedTools'] + alwaysLoaded: PtcConfig['alwaysLoaded'] +} + +/** Every key a mode row accepts: the settings each enhanced preset really reads. */ +export const MODE_CONFIG_KEYS: readonly string[] = Object.freeze([ + ...LEGACY_FASTCTX_KEYS, + 'binaryPath', 'required', 'denyCredentialReads', 'promptPolicy', 'extraGuidance', + 'bashPath', 'publishBashTool', 'allowSystemShellFallback', 'configUpdateVersion', + 'allowedTools', 'alwaysLoaded', +]) + +const PTC_KEYS = new Set(['allowedTools', 'alwaysLoaded']) + +/** + * Validate one mode row's config. + * @param rawConfig - the loader row's config value. + * @returns the resolved configuration. + * @throws ConfigError naming an unknown or malformed key. + */ +export function resolveModeConfig(rawConfig?: unknown): ModeConfig { + if (rawConfig !== undefined && rawConfig !== null && (typeof rawConfig !== 'object' || Array.isArray(rawConfig))) { + throw new Error('config must be an object') + } + const raw = (rawConfig ?? {}) as Record + for (const key of Object.keys(raw)) { + if (!MODE_CONFIG_KEYS.includes(key)) { + throw new Error(`unknown key(s) ${JSON.stringify(key)}; accepted keys are ${MODE_CONFIG_KEYS.map(entry => JSON.stringify(entry)).join(', ')}`) + } + } + const core = Object.fromEntries(Object.entries(raw).filter(([key]) => !PTC_KEYS.has(key))) + const ptc = Object.fromEntries(Object.entries(raw).filter(([key]) => PTC_KEYS.has(key))) + const resolved = resolveConfig(core) + const api = parsePtcConfig(ptc) + return { ...resolved, allowedTools: api.allowedTools, alwaysLoaded: api.alwaysLoaded } +} + +/** + * Editable settings fields for one mode row, in the order the host renders them. + * @param mode - the mode the row enables. + * @returns field descriptors for the row settings form. + */ +export function modeFields(mode: ModeId): Record { + const shared: Record = { + publishBashTool: editable('boolean', 'Publish Bash for authorized sessions.'), + bashPath: editable('string', 'Absolute Bash executor path. Remove the override to select the optional managed installation.'), + } + if (mode === 'minimal') return shared + if (mode === 'ptc') return { + required: editable('boolean', 'Fail startup if the native worker is unavailable.'), + binaryPath: editable('string', 'Optional explicit native worker path.'), + allowedTools: editable('array', 'PTC APIs available for SDK selection.', { type: 'string' }), + alwaysLoaded: editable('array', 'PTC APIs published before SDK selection.', { type: 'string' }), + promptPolicy: editable('boolean', 'Publish tool routing guidance.'), + } + return { + ...shared, + promptPolicy: editable('boolean', 'Publish tool routing guidance.'), + extraGuidance: editable('string', 'Additional tool routing guidance.'), + binaryPath: editable('string', 'Optional explicit native worker path; omit to use the packaged worker.'), + required: editable('boolean', 'Fail activation when the native worker is unavailable.'), + denyCredentialReads: editable('boolean', 'Refuse credential locations during file reads.'), + } +} + +/** + * The settings descriptor one mode component exports as `Config`. + * @param mode - the mode the row enables. + * @returns a settings descriptor that validates with {@link resolveModeConfig}. + */ +export function modeConfigForm(mode: ModeId) { + return settingsConfig(value => { + const raw = configObject(value ?? {}) + const resolved = resolveModeConfig(raw) + // Keep raw ordinary keys: the host saves individual paths, and the component + // owns normalization when the row activates. + return { ...raw, publishBashTool: resolved.publishBashTool, promptPolicy: resolved.promptPolicy } + }, modeFields(mode)) +} diff --git a/src/modes/detect.ts b/src/modes/detect.ts new file mode 100644 index 00000000..f85dbfd2 --- /dev/null +++ b/src/modes/detect.ts @@ -0,0 +1,67 @@ +/** + * Identify the agent preset a live Agent was composed from. + * + * The preset id is the only authority for which OPS mode applies: a preset is a + * scoped plugin composition, and a prompt or tool name is not a preset identity. + * The value comes from the public preset registry when that service is mounted; + * hosts without a preset roster fall back to the session projection or the frozen + * session header. A host with no preset registry at all is a single-composition + * deployment (headless/SDK), which this plugin treats as the built-in Standard + * composition so OPS keeps working there. + */ +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { MODE_IDS, MODE_PRESET, type ModeId } from './types.js' + +interface PresetRegistry { composedPreset(ctx: Context): string | undefined } +interface SessionProjections { stateOf(session: unknown, key: string): unknown } +interface AgentSession { header?: { agentPreset?: unknown } } + +const modeByPreset = new Map(MODE_IDS.map(mode => [MODE_PRESET[mode], mode])) + +/** Read an optional service by name without requiring its type package at load time. */ +function service(ctx: Context, name: string): T | undefined { + try { + const getter = (ctx as unknown as { get?: (key: string) => unknown }).get + const value = typeof getter === 'function' ? Reflect.apply(getter, ctx, [name]) : undefined + return value === undefined || value === null ? undefined : value as T + } catch { return undefined } +} + +/** + * The preset id an Agent is currently composed from. + * @param ctx - a context on the same Cordis root as the Agent. + * @param agent - the live Agent. + * @returns the preset id, or undefined when the host composes no preset for it. + */ +export function presetOf(ctx: Context, agent: Agent): string | undefined { + const registry = service(ctx, 'agentPresets') + if (registry !== undefined) { + try { + const id = registry.composedPreset?.(agent.ctx) + if (typeof id === 'string' && id !== '') return id + } catch { /* a collected generation is not a preset for this Agent */ } + } + const projections = service(ctx, 'sessionProjections') + try { + const state = projections?.stateOf?.(agent.session, 'agentPreset') + if (typeof state === 'string' && state !== '') return state + } catch { /* projection unavailable */ } + const header = (agent.session as AgentSession | undefined)?.header + const declared = header?.agentPreset + return typeof declared === 'string' && declared !== '' ? declared : undefined +} + +/** + * The OPS mode that applies to an Agent. + * @param ctx - a context on the same Cordis root as the Agent. + * @param agent - the live Agent. + * @returns the mode, or undefined when no enhanced preset applies. + */ +export function modeOf(ctx: Context, agent: Agent): ModeId | undefined { + const preset = presetOf(ctx, agent) + if (preset !== undefined) return modeByPreset.get(preset) + // No preset roster means one deployment-wide composition; treat it as the + // built-in Standard preset so a headless/SDK host keeps the OPS surface. + return service(ctx, 'agentPresets') === undefined ? 'standard' : undefined +} diff --git a/src/modes/native.ts b/src/modes/native.ts new file mode 100644 index 00000000..3d5f6368 --- /dev/null +++ b/src/modes/native.ts @@ -0,0 +1,141 @@ +/** + * Resident native file surface for one enhanced preset mode. + * + * OPS file tools are registered per matching Agent at that Agent's scope, so a + * session only sees the surface of the preset it was composed from and two mode + * rows never compete for one registration. The controller re-reads the preset + * when the registry reports a change, which is the only way a blank session can + * move between modes. + */ +import type { Context, Fiber } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-system-prompt' +import { NativeFileTools } from '../file/runtime.js' +import { registerNativeProviderAtScope } from '../file/result-provider.js' +import { resolveWorker } from '../file/worker.js' +import { mountResultLifecycle } from '../kernel/result-lifecycle.js' +import { BASH_TOOL, modeSectionName, MODE_SECTION_ORDERS, renderToolingPolicy } from '../policy.js' +import { visibleNames } from '../surface.js' +import type { ModeConfig } from './config.js' +import { modeOf } from './detect.js' +import { NATIVE_RESIDENT_RAW, MODE_PLANS } from './plan.js' +import { onModesChange } from './registry.js' +import type { ModeId } from './types.js' + +type ResidentName = Parameters[0] + +interface Entry { + agent: Agent + ctx?: Context + fiber?: Fiber + runtime: NativeFileTools + owned: Set + disposers: (() => void)[] + ready: Promise +} + +/** + * Publish the resident native file tools to sessions of one mode. + * @param root - the mode row's context. + * @param config - the row's resolved configuration. + * @param mode - the mode the row enables. + * @returns void; the caller owns the row lifetime through `ctx.effect`. + */ +export function mountNativeMode(root: Context, config: ModeConfig, mode: ModeId): void { + let worker: string + try { worker = resolveWorker({ binaryPath: config.binaryPath }) } catch (error) { + if (config.required) throw error + root.logger.warn(`dsh-ops: ${mode} mode cannot resolve its native worker; native file tools stay unpublished. ${String((error as Error)?.message ?? error)}`) + return + } + const plan = MODE_PLANS[mode] + root.inject(['tools', 'systemPrompt', 'sandboxPolicy', 'subprocess'], async ctx => { + const tools = ctx.get('tools') + const prompt = ctx.get('systemPrompt') + if (!tools || !prompt) return + mountResultLifecycle(ctx) + const entries = new Map() + let stopped = false, mutating = false + const clear = (entry: Entry): void => { + entry.owned.clear() + for (const dispose of entry.disposers.splice(0)) { + try { dispose() } catch (error) { ctx.logger.warn(`dsh-ops: ${mode} native publication cleanup failed: ${String(error)}`) } + } + entry.runtime.cancel('Mode row ownership changed') + void entry.runtime.stop().catch(error => ctx.logger.warn(`dsh-ops: ${mode} native runtime stop failed: ${String(error)}`)) + } + const publish = (agent: Agent, entry: Entry): void => { + if (!entry.ctx) return + const scope = agent + const definitions = NATIVE_RESIDENT_RAW.map(name => entry.runtime.definition(name as ResidentName)) + const dispose = registerNativeProviderAtScope(entry.ctx, scope, definitions, + tool => entry.ctx!.tools.register(tool), { inheritable: false, priority: 10 }) + entry.disposers.push(dispose) + for (const definition of definitions) entry.owned.add(definition.name) + } + const cover = (agent: Agent): boolean => { + const current = modeOf(ctx, agent) + return current === mode + } + const attach = (agent: Agent): void => { + if (stopped || entries.has(agent) || !agent?.ctx) return + const entry: Entry = { agent, runtime: new NativeFileTools({ ctx, config: { workerPath: worker, required: config.required, denyCredentialReads: config.denyCredentialReads } }), + owned: new Set(), disposers: [], ready: Promise.resolve() } + entries.set(agent, entry) + entry.fiber = agent.ctx.plugin({ inject: ['tools'], apply(scoped) { entry.ctx = scoped } }) + entry.ready = Promise.resolve(entry.fiber).then(() => { + if (stopped || entries.get(agent) !== entry || !entry.ctx) return + return entry.runtime.start([]).then(() => { + if (stopped || entries.get(agent) !== entry || !cover(agent)) return + publish(agent, entry) + }) + }).catch(error => ctx.logger.warn(`dsh-ops: ${mode} mode publication failed: ${String(error)}`)) + } + const detach = (agent: Agent): void => { + const entry = entries.get(agent) + if (!entry) return + entries.delete(agent) + clear(entry) + void entry.fiber?.dispose() + } + const reconcile = (): void => { + if (stopped || mutating) return + mutating = true + try { + for (const [agent, entry] of entries) if (!cover(agent)) detach(agent) + for (const agent of ctx.get('agents')?.list?.() ?? []) if (cover(agent)) attach(agent) + } finally { mutating = false } + } + ctx.on('agent/created', ({ agent }) => { if (cover(agent)) attach(agent) }) + ctx.on('agent/disposed', ({ agent }) => detach(agent)) + ctx.on('session/event', (_session, event) => { + // A blank session may select another preset before its first turn; the mode + // that no longer matches must withdraw its surface. + if ((event as { type: string }).type === 'agent-preset/selected') reconcile() + }) + ctx.effect(() => onModesChange(ctx, reconcile), `dsh-ops: ${mode} mode registry observer`) + for (const agent of ctx.get('agents')?.list?.() ?? []) if (cover(agent)) attach(agent) + if (plan.section !== null) { + const suffix = `mode-${plan.section}` + ctx.effect(() => prompt.section({ + name: modeSectionName(suffix), order: MODE_SECTION_ORDERS[suffix] ?? 3250, interpolate: false, + text: context => { + const agent = context.agent + if (agent === undefined || !config.promptPolicy) return '' + const entry = entries.get(agent) + if (!entry || entry.owned.size === 0) return '' + const visible = new Set(visibleNames(tools, agent)) + const published = [...entry.owned].filter(name => visible.has(name)) + // The Windows shell replacement is published by the shared shell row; + // name it here when it is visible so routing text stays complete. + if (visible.has(BASH_TOOL)) published.push(BASH_TOOL) + return renderToolingPolicy({ published, shellComponent: true, extraGuidance: config.extraGuidance }) + }, + }), `dsh-ops: ${mode} tooling guidance`) + } + ctx.effect(() => () => { + stopped = true + for (const agent of [...entries.keys()]) detach(agent) + }, `dsh-ops: ${mode} native lifecycle`) + }) +} diff --git a/src/modes/plan.ts b/src/modes/plan.ts new file mode 100644 index 00000000..9d7489ab --- /dev/null +++ b/src/modes/plan.ts @@ -0,0 +1,54 @@ +/** + * What each mode contributes to a session of its preset. + * + * This table is the product contract between the four mode rows and the shared + * publication controllers: resident OPS tools, official tools replaced once + * their OPS counterpart is visible, and whether the mode consumes the advanced + * file tools through the discovery entry. + */ +import type { OfficialReplacement } from '../capabilities/replacements.js' +import { publicToolName } from '../policy.js' +import type { ModeId } from './types.js' + +/** Raw resident native file capabilities: one per replaced official tool plus the multi-file reader. */ +export const NATIVE_RESIDENT_RAW = Object.freeze(['inspect_local_file', 'glob', 'grep', 'read_files', 'result'] as const) + +/** Official Windows shell names the host may publish. Only visible ones are replaced. */ +export const SHELL_OFFICIAL_NAMES: readonly string[] = Object.freeze(['pwsh', 'pwsh_persistent']) + +/** One mode's publication and replacement policy. */ +export interface ModePlan { + /** Publish the resident native file tools for matching Agents. */ + nativeFiles: boolean + /** Official file tools replaced once their OPS counterpart is visible in the same scope. */ + replacements: readonly OfficialReplacement[] + /** Replace the official Windows shell with ops_bash when the row is on. */ + shell: boolean + /** Publish the PTC SDK APIs for matching Agents. */ + ptc: boolean + /** Progressive discovery population for matching Agents. */ + discovery: 'none' | 'advanced' | 'all' + /** The mode's routing section suffix; null publishes no routing text. */ + section: string | null +} + +const NATIVE_REPLACEMENTS: readonly OfficialReplacement[] = Object.freeze(['read', 'glob', 'grep']) + +/** Everything one mode publishes; consumers read this instead of branching on ids. */ +export const MODE_PLANS: Readonly> = Object.freeze({ + // Minimal keeps its terse prompts: only the Windows shell is replaced, and + // advanced tools arrive through the discovery entry when that row is on. + minimal: Object.freeze({ nativeFiles: false, replacements: [], shell: true, ptc: false, discovery: 'all', section: null }), + standard: Object.freeze({ nativeFiles: true, replacements: NATIVE_REPLACEMENTS, shell: true, ptc: false, discovery: 'advanced', section: 'standard' }), + ptc: Object.freeze({ nativeFiles: false, replacements: [], shell: true, ptc: true, discovery: 'none', section: 'ptc' }), + cordis: Object.freeze({ nativeFiles: true, replacements: NATIVE_REPLACEMENTS, shell: true, ptc: false, discovery: 'advanced', section: 'cordis' }), +}) + +/** + * The resident native capability names of one mode. + * @param mode - the mode to read. + * @returns public OPS names, or an empty list when the mode publishes no native file tools. + */ +export function residentTools(mode: ModeId): readonly string[] { + return MODE_PLANS[mode].nativeFiles ? NATIVE_RESIDENT_RAW.map(raw => publicToolName(raw)) : [] +} diff --git a/src/modes/registry.ts b/src/modes/registry.ts new file mode 100644 index 00000000..5eceff3e --- /dev/null +++ b/src/modes/registry.ts @@ -0,0 +1,100 @@ +/** + * Cross-row state for the mode switches. + * + * Mode rows, the Compatibility switch, and the discovery row are independent + * Loader rows; they share one small registry keyed by the Cordis root so each + * row can see which modes are on without importing another row's module state. + */ +import type { Context } from '@deepseek-ai/cordis' +import type { ModeConfig } from './config.js' +import type { ModeId } from './types.js' + +declare module '@deepseek-ai/cordis' { + interface Events { + /** One mode row or the Compatibility switch changed its enabled state or config. */ + 'dsh-ops/modes'(): void + } +} + +interface State { + modes: Map + compat: boolean + muted: number +} + +const states = new WeakMap() + +/** The shared state of one Cordis root. */ +function stateOf(ctx: Context): State { + const key: object = ctx.root ?? ctx + let state = states.get(key) + if (state === undefined) { state = { modes: new Map(), compat: false, muted: 0 }; states.set(key, state) } + return state +} + +/** Emit one change, unless a batch is in progress. */ +function notify(ctx: Context): void { + const state = stateOf(ctx) + if (state.muted > 0) return + try { ctx.emit('dsh-ops/modes') } catch { /* an observer's failure is reported by that observer */ } +} + +/** + * Register one enabled mode row. + * @param ctx - the row's context. + * @param id - the mode the row enables. + * @param config - the row's resolved configuration. + * @returns the disposer that unregisters the mode. + */ +export function joinMode(ctx: Context, id: ModeId, config: ModeConfig): () => void { + const state = stateOf(ctx) + state.modes.set(id, config) + notify(ctx) + return () => { + if (state.modes.get(id) !== config) return + state.modes.delete(id) + notify(ctx) + } +} + +/** + * Record the Compatibility switch. + * @param ctx - the switch row's context. + * @param enabled - whether official tools stay visible. + * @returns the disposer that clears the switch. + */ +export function joinCompatibility(ctx: Context, enabled: boolean): () => void { + const state = stateOf(ctx) + const previous = state.compat + state.compat = enabled + notify(ctx) + return () => { state.compat = previous; notify(ctx) } +} + +/** + * The enabled modes and their configuration. + * @param ctx - any context on the root. + * @returns a snapshot keyed by mode id. + */ +export function enabledModes(ctx: Context): ReadonlyMap { + return new Map(stateOf(ctx).modes) +} + +/** + * Whether the Compatibility switch is on. + * @param ctx - any context on the root. + * @returns true when official tools must stay exposed. + */ +export function compatibilityOn(ctx: Context): boolean { + return stateOf(ctx).compat +} + +/** + * Observe every mode/compatibility change. + * @param ctx - the row's context; the listener is disposed with it. + * @param listener - the callback to run after a change. + * @returns the unsubscribe function. + */ +export function onModesChange(ctx: Context, listener: () => void): () => void { + return ctx.on('dsh-ops/modes', listener) +} diff --git a/src/modes/replacements.ts b/src/modes/replacements.ts new file mode 100644 index 00000000..67567b4e --- /dev/null +++ b/src/modes/replacements.ts @@ -0,0 +1,123 @@ +/** + * Official-tool replacement for one enhanced preset mode. + * + * A mode hides only the official names whose OPS counterpart is visible in the + * same Agent scope, and only while Compatibility mode is off. Nothing is hidden + * when the counterpart is missing: a session keeps the official workflow rather + * than losing a capability to an unfinished publication. + */ +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ToolGuard } from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-system-prompt' +import { OFFICIAL_REPLACEMENTS } from '../official-tool-catalog.js' +import { BASH_TOOL } from '../policy.js' +import { visibleNames } from '../surface.js' +import { onToolChange } from '../tool-changes.js' +import type { ModeConfig } from './config.js' +import { modeOf } from './detect.js' +import { MODE_PLANS, SHELL_OFFICIAL_NAMES } from './plan.js' +import { compatibilityOn, onModesChange } from './registry.js' +import type { ModeId } from './types.js' + +interface Entry { lifts: (() => void)[]; deny: string[] } + +/** Official names one mode can hide, with the OPS tool that replaces each. */ +export function replacementTargets(mode: ModeId, platform: string = process.platform): ReadonlyMap { + const targets = new Map() + for (const official of MODE_PLANS[mode].replacements) targets.set(official, OFFICIAL_REPLACEMENTS[official]) + if (MODE_PLANS[mode].shell && platform === 'win32') for (const official of SHELL_OFFICIAL_NAMES) targets.set(official, BASH_TOOL) + return targets +} + +/** + * Hide the official tools one mode replaces, for sessions composed from that mode. + * @param root - the mode row's context. + * @param config - the row's resolved configuration. + * @param mode - the mode the row enables. + * @returns void; the caller owns the row lifetime through `ctx.effect`. + */ +export function mountReplacements(root: Context, _config: ModeConfig, mode: ModeId): void { + const targets = replacementTargets(mode) + if (targets.size === 0) return + root.inject(['tools'], ctx => { + const tools = ctx.get('tools') + if (!tools || typeof tools.restrict !== 'function') return + const entries = new Map() + let stopped = false, mutating = false + const matches = (agent: Agent): boolean => modeOf(ctx, agent) === mode + const lift = (entry: Entry): void => { + entry.deny = [] + for (const dispose of entry.lifts.splice(0)) { + try { dispose() } catch (error) { ctx.logger.warn(`dsh-ops: ${mode} replacement cleanup failed: ${String(error)}`) } + } + } + // A hidden official tool disappears from the visible view, so "still visible" + // cannot decide whether to keep hiding: a target stays denied while its OPS + // counterpart is visible and it is either still visible or already denied by + // this row. Reading only current visibility would lift and re-apply the mask + // on every registry change and oscillate forever. + const plan = (agent: Agent, entry: Entry): string[] => { + if (compatibilityOn(ctx)) return [] + const visible = new Set(visibleNames(tools, agent)) + return [...targets] + .filter(([official, counterpart]) => visible.has(counterpart) && (visible.has(official) || entry.deny.includes(official))) + .map(([official]) => official) + } + const reconcile = (): void => { + if (stopped || mutating) return + mutating = true + try { + for (const agent of ctx.get('agents')?.list?.() ?? []) if (matches(agent) && !entries.has(agent)) entries.set(agent, { lifts: [], deny: [] }) + for (const [agent, entry] of entries) { + if (!matches(agent)) { lift(entry); entries.delete(agent); continue } + const deny = plan(agent, entry) + if (deny.length === entry.deny.length && deny.every(name => entry.deny.includes(name))) continue + lift(entry) + if (deny.length === 0) continue + try { + entry.lifts.push(agent.ctx.tools.restrict({ deny })) + entry.deny = deny + } catch (error) { ctx.logger.warn(`dsh-ops: ${mode} replacement could not hide ${deny.join(', ')}: ${String(error)}`) } + } + } finally { mutating = false } + } + ctx.on('agent/created', ({ agent }) => { if (matches(agent)) { entries.set(agent, { lifts: [], deny: [] }); reconcile() } }) + ctx.on('agent/disposed', ({ agent }) => { + const entry = entries.get(agent) + if (!entry) return + lift(entry) + entries.delete(agent) + }) + ctx.on('session/event', (_session, event) => { if ((event as { type: string }).type === 'agent-preset/selected') reconcile() }) + ctx.effect(() => onModesChange(ctx, reconcile), `dsh-ops: ${mode} replacement registry observer`) + onToolChange(ctx, () => reconcile()) + // Reconcile on the way into an assembly so a Compatibility change is visible + // for the request being assembled, not one request later. + ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { reconcile(); return next() }) + const refusal: ToolGuard = exec => { + if (stopped || compatibilityOn(ctx)) return undefined + const agent = exec?.agent + if (!agent || !matches(agent)) return undefined + const counterpart = targets.get(String(exec.name ?? '')) + if (counterpart === undefined) return undefined + if (!visibleNames(tools, agent).includes(counterpart)) return undefined + return `dsh-ops ${mode} mode replaced the official tool ${String(exec.name)} with ${counterpart}; call ${counterpart} or ask the operator to enable Compatibility mode.` + } + ctx.on('tools/pre-execute', async (exec, next) => { + const reason = refusal(exec) + return reason ? { kind: 'deny', reason, info: { name: 'ReplacementError', code: 'MODE_REPLACEMENT' } } : next() + }) + const guard = (tools as unknown as { guard?: (listener: ToolGuard) => (() => void) | void }).guard + if (typeof guard === 'function') ctx.effect(() => { + const dispose = guard.call(tools, refusal) + return typeof dispose === 'function' ? dispose : () => {} + }, `dsh-ops: ${mode} replacement guard`) + ctx.effect(() => () => { + stopped = true + for (const entry of entries.values()) lift(entry) + entries.clear() + }, `dsh-ops: ${mode} replacements`) + reconcile() + }) +} diff --git a/src/modes/types.ts b/src/modes/types.ts new file mode 100644 index 00000000..b684cb34 --- /dev/null +++ b/src/modes/types.ts @@ -0,0 +1,18 @@ +/** + * The built-in DSH agent presets OPS can enhance. + * + * One mode row exists per preset: enabling it makes OPS publish its surface for + * sessions composed from that preset and, unless Compatibility mode is on, hide + * the official tools that surface replaces. + */ +export const MODE_IDS = Object.freeze(['minimal', 'standard', 'ptc', 'cordis'] as const) +/** One enhanced built-in preset. */ +export type ModeId = typeof MODE_IDS[number] + +/** The agent-preset id each mode enhances; the preset ids are the built-in ids. */ +export const MODE_PRESET: Readonly> = Object.freeze({ + minimal: 'minimal', standard: 'standard', ptc: 'ptc', cordis: 'cordis', +}) + +/** The preset a minimal-mode session uses even when the Minimal row is off. */ +export const MINIMAL_PRESET = MODE_PRESET.minimal diff --git a/src/official-tool-catalog.ts b/src/official-tool-catalog.ts index 697c108d..1e8ef65e 100644 --- a/src/official-tool-catalog.ts +++ b/src/official-tool-catalog.ts @@ -5,7 +5,6 @@ * See the paired component references before opting into a different workflow. */ import { publicToolName } from './policy.js' -import { componentEnabled } from './components/defaults.js' import { RAW_OFFICIAL_REPLACEMENTS } from './capabilities/replacements.js' import type { OfficialReplacement } from './capabilities/replacements.js' export type { OfficialReplacement } from './capabilities/replacements.js' @@ -19,5 +18,7 @@ export const OFFICIAL_REPLACEMENTS = Object.freeze(Object.fromEntries( export function platformSurface(platform: string = process.platform) { if (!['win32', 'linux', 'darwin'].includes(platform)) throw new Error(`Unsupported official-tool hiding platform: ${platform}`) - return { retainedCommand: platform === 'win32' ? 'pwsh' : 'bash', shellDefaultEnabled: componentEnabled('shell', platform) } + // The legacy shell row is always enabled where it is inserted; mode rows carry + // the shell policy now, so this retention fact no longer depends on rosters. + return { retainedCommand: platform === 'win32' ? 'pwsh' : 'bash', shellDefaultEnabled: true } } diff --git a/src/policy.ts b/src/policy.ts index 018aae14..2f6dec02 100644 --- a/src/policy.ts +++ b/src/policy.ts @@ -6,6 +6,16 @@ export const TOOLING_SECTION = 'dsh-ops:repository-tooling' // Legacy section name kept for consumers; the plugin no longer publishes it. export const HOST_SHELL_SECTION = 'dsh-ops:host-shell-policy' export const SECTION_ORDERS = Object.freeze({ [TOOLING_SECTION]: 3250 }) +/** Prompt-section suffix and order for one enhanced preset row. */ +export const MODE_SECTION_ORDERS: Readonly> = Object.freeze({ + 'mode-standard': 3250, 'mode-ptc': 3251, 'mode-cordis': 3252, +}) +/** + * The prompt section one mode row publishes. + * @param suffix - the mode section suffix, for example `mode-standard`. + * @returns the section name. + */ +export function modeSectionName(suffix: string): string { return `${TOOLING_SECTION}:${suffix}` } export const TOOL_PREFIX = 'ops_' // The plugin's second layer executes POSIX commands; pwsh7 uses the host tool. export function publicToolName(rawName: string) { return `${TOOL_PREFIX}${rawName}` } diff --git a/src/profiles/client/package.json b/src/profiles/client/package.json index 5bd73bd3..37a22fea 100644 --- a/src/profiles/client/package.json +++ b/src/profiles/client/package.json @@ -2,6 +2,6 @@ "name": "dsh-ops-client-profile", "private": true, "type": "module", - "description": "Optional seven-row OPS bundle with a browser settings carrier; select instead of the default bundle.", + "description": "Optional eight-row OPS bundle with a browser settings carrier; select instead of the default bundle.", "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } } diff --git a/src/ptc/contract.ts b/src/ptc/contract.ts index 25917fab..85e277be 100644 --- a/src/ptc/contract.ts +++ b/src/ptc/contract.ts @@ -10,7 +10,7 @@ import { toOutcome } from '../modes/ptc/outcome.js' import { WORKER_TIMEOUT_MS } from '../worker-protocol.js' export { PTC_TOOLS, PARAMETERS, DESCRIPTIONS, OUTPUT } export const PTC_API_TOOLS = Object.freeze([...FILE_TOOLS, ...PTC_TOOLS]) -export const PTC_SECTION = 'dsh-ops:repository-tooling:ptc' +export const PTC_SECTION = 'dsh-ops:repository-tooling:mode-ptc' export function definition(tool: FileToolName | PtcToolName, execute: ToolDefinition['execute']): ToolDefinition { const entry = capability(tool) if (!entry || !('parameters' in entry)) throw new Error(`Missing PTC capability: ${tool}`) diff --git a/src/ptc/index.ts b/src/ptc/index.ts index 9441d9ae..39444d10 100644 --- a/src/ptc/index.ts +++ b/src/ptc/index.ts @@ -49,7 +49,15 @@ const isPtc = (assembly: Presentation): boolean => Array.isArray(assembly.tools) const isPtcPresentation = (presentation: Presentation): boolean => Object.hasOwn(presentation, 'tools') ? isPtc(presentation) : hasPtcInstruction(presentation) -export function apply(root: Context, rawConfig: unknown): void { +/** A mode row's per-Agent permission to publish the PTC surface. */ +export interface PtcGate { + /** Whether the row owns this Agent's mode. */ + allows(agent: Agent): boolean + /** Subscribe to gate changes; the callback runs a full reconcile. */ + watch?(notify: () => void): () => void +} + +export function apply(root: Context, rawConfig: unknown, gate: PtcGate = { allows: () => true }): void { const config = resolvePtcConfig(rawConfig) mountWorkspacePreparation(root) let file: string @@ -141,7 +149,7 @@ export function apply(root: Context, rawConfig: unknown): void { if (!fresh && entry.surface === surface) { if (entry.ptc) ready.add(agent); continue } if (entry.surface !== surface || fresh) { dirty = true; changed.add(agent) } probedBefore.add(agent) - const next = await task.wait(ptcOnly(agent, task.signal)).catch(() => false) + const next = gate.allows(agent) && await task.wait(ptcOnly(agent, task.signal)).catch(() => false) if (next !== entry.ptc) { dirty = true; changed.add(agent) } if (next) ready.add(agent) } @@ -283,6 +291,7 @@ export function apply(root: Context, rawConfig: unknown): void { return prompt.assemble(context) }) ctx.on('session/event', (session, event) => { + if ((event as { type: string }).type === 'agent-preset/selected') { void reconcile(true); return } if (event.type === 'step/end') { let changed = false const affected = new Set() @@ -300,6 +309,7 @@ export function apply(root: Context, rawConfig: unknown): void { void reconcile(true, affected.size ? affected : undefined) }) ;({ scopeChainOf } = await import('@deepseek-ai/dsh-scope')) + if (gate.watch) ctx.effect(() => gate.watch!(() => { void reconcile(true) }), 'dsh-ops: PTC mode observer') ownership = await ownPtcScopes(ctx, () => { mutating = true try { diff --git a/src/session-shells.ts b/src/session-shells.ts index 86ee254a..b5e0859c 100644 --- a/src/session-shells.ts +++ b/src/session-shells.ts @@ -134,7 +134,15 @@ interface Entry { published?: boolean; generation?: number } -export function mountSessionBash(ctx: Context, config: Pick, shells: { bash: ShellResolution }) { +/** A row's per-Agent permission to publish its bash layer. */ +export interface BashGate { + /** Whether the row owns this Agent's mode. */ + allows(agent: Agent): boolean + /** Subscribe to gate changes; the callback runs a full reconcile. */ + watch?(notify: () => void): () => void +} + +export function mountSessionBash(ctx: Context, config: Pick, shells: { bash: ShellResolution }, gate: BashGate = { allows: () => true }) { if (!config.publishBashTool) return const bash = shells.bash if (!bash.available) { @@ -155,6 +163,7 @@ export function mountSessionBash(ctx: Context, config: Pick { if (!ready || stopped || !agent?.session) return false + if (!gate.allows(agent)) return false try { const policy = ctx.get('sandboxPolicy')?.resolve({ session: agent.session }) return policy !== undefined && MODES.includes(policy.mode) && (policy.mode === 'danger-full-access' || !!ctx.get('sandbox')?.confine) @@ -284,7 +293,15 @@ export function mountSessionBash(ctx: Context, config: Pick { if (event.type === 'sandbox/mode') scheduleReconcile() }) + ctx.on('session/event', (_session, event) => { + const type = (event as { type: string }).type + // A sandbox-mode change only re-resolves authority; republishing every sibling + // definition would break the stable-definition contract. A preset selection can + // change which Agent this row serves, so it must rebuild definitions. + if (type === 'sandbox/mode') scheduleReconcile() + else if (type === 'agent-preset/selected') scheduleReconcile(true) + }) + if (gate.watch) ctx.effect(() => gate.watch!(() => { void scheduleReconcile(true) }), 'dsh-ops: bash mode observer') ctx.inject(['sandbox'], () => { scheduleReconcile() }) ctx.inject(['jobs'], jobsCtx => { jobs = jobsCtx.get('jobs') as ShellJobs | undefined diff --git a/src/settings/carrier-plan.ts b/src/settings/carrier-plan.ts index 9e869649..17e19c1d 100644 --- a/src/settings/carrier-plan.ts +++ b/src/settings/carrier-plan.ts @@ -1,11 +1,15 @@ -/** Offline planning against the host's actual persistent profile composition. */ +/** + * Offline planning against the host's actual persistent profile composition. + * + * The one-time client-carrier migration switches the profile bundle name and + * preserves every composed row; it never rewrites saved patch rows. + */ import fs from 'node:fs' import path from 'node:path' import { isDeepStrictEqual } from 'node:util' import type * as Boot from '@deepseek-ai/dsh-app-boot' import type * as Yaml from 'yaml' import type * as Atomic from '@deepseek-ai/dsh-atomic-write' -import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { COMPONENTS } from '../components/defaults.js' @@ -13,7 +17,7 @@ export const CLIENT_BUNDLE = 'dsh-ops/lib/profiles/client' export interface CarrierHost { boot: typeof Boot; yaml: typeof Yaml; atomic: typeof Atomic } export interface CarrierLocation { dir: string; home: string; installAnchor: string } export interface FileChange { path: string; before?: string; after: string; mode: number } -export interface CarrierPlan { files: FileChange[]; changedQualifiers: number; beforeRows: EntryOptions[]; afterRows: EntryOptions[]; location: CarrierLocation } +export interface CarrierPlan { files: FileChange[]; beforeRows: EntryOptions[]; afterRows: EntryOptions[]; location: CarrierLocation } function object(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Expected an object in the profile manifest.') return value as Record @@ -47,34 +51,34 @@ export function prepareCarrier(host: CarrierHost, location: CarrierLocation): Ca // Equal values today do not erase a saved override's intent when defaults change later. const external = host.boot.readProfilePatches('dsh', context, { ...loaded, patches: [], layers: loaded.layers.filter(layer => layer.packageName !== 'dsh-ops') }) - if (external.some(patch => !patch.insert && patch.id === 'dsh-ops-file' && patch.name === 'dsh-ops/file')) throw new Error('External name-qualified file overrides require manual review; nothing was written.') + // The optional layer renames only the standard-mode row to the package root, so + // an external name-qualified override of that row would no longer match; never + // rewrite it automatically. + if (external.some(patch => !patch.insert && patch.id === 'dsh-ops-mode-standard' && patch.name === 'dsh-ops/mode-standard')) throw new Error('External name-qualified standard-mode overrides require manual review; nothing was written.') const original = host.boot.composeEntries([host.boot.readProfilePatches('dsh', context, loaded)]) const own = flatten(original).filter(row => row.id?.startsWith('dsh-ops-')) - if (own.length !== 7 || COMPONENTS.some(name => own.filter(row => row.id === `dsh-ops-${name}` && row.name === `dsh-ops/${name}`).length !== 1)) throw new Error('Expected the seven unique legacy-named OPS rows; custom insertions or shadowing require manual review.') - const document = host.yaml.parseDocument(patchFile.text ?? '[]\n', { customTags: [{ tag: 'tag:yaml.org,2002:js', resolve: (value: string) => value }] }) - if (document.errors.length || !host.yaml.isSeq(document.contents)) throw new Error('Profile patch must be a valid YAML sequence.') - let changedQualifiers = 0 - for (const node of document.contents.items) { - if (host.yaml.isMap(node) && node.get('id', true)?.toJSON() === 'dsh-ops-file' && !node.has('insert') && node.get('name', true)?.toJSON() === 'dsh-ops/file') { - node.delete('name'); changedQualifiers++ - } + if (own.length !== COMPONENTS.length || COMPONENTS.some(component => own.filter(row => row.id === `dsh-ops-${component}` && row.name === `dsh-ops/${component}`).length !== 1)) { + throw new Error(`Expected exactly ${COMPONENTS.length} unique OPS component rows (${COMPONENTS.join(', ')}); custom insertions or shadowing require manual review.`) } - const patchAfter = changedQualifiers ? String(document) : patchFile.text ?? '[]\n' - const parsed = host.yaml.parseDocument(patchAfter, { customTags: [{ tag: 'tag:yaml.org,2002:js', resolve: (value: string) => ({ __jsExpr: value }) }] }).toJS() as PatchOptions[] - const neutral = { ...loaded, patches: parsed } - if (!isDeepStrictEqual(host.boot.composeEntries([host.boot.readProfilePatches('dsh', context, neutral)]), original)) throw new Error('Removing a file-row matcher would change effective configuration; nothing was written.') + // The migration never rewrites a saved row: keep the patch text byte-identical and + // validate it here so the profile composer sees the same valid sequence. + const patchAfter = patchFile.text ?? '[]\n' + const document = host.yaml.parseDocument(patchAfter, { customTags: [{ tag: 'tag:yaml.org,2002:js', resolve: (value: string) => value }] }) + if (document.errors.length || !host.yaml.isSeq(document.contents)) throw new Error('Profile patch must be a valid YAML sequence.') const parent = loaded.layers.find(layer => layer.packageName === 'dsh-ops')! const directory = host.boot.resolveBundleDir('dsh', CLIENT_BUNDLE, location.installAnchor, location.dir) if (fs.realpathSync(directory) !== fs.realpathSync(path.join(parent.packageDir, 'lib/profiles/client'))) throw new Error('The optional layer must belong to the selected OPS installation.') const patch = path.join(directory, 'cordis.patch.yml') const alternate = { packageName: CLIENT_BUNDLE, packageDir: directory, patchPaths: [patch], patches: host.boot.loadOverlayPatches('dsh', patch) } - const candidate = { ...neutral, layers: loaded.layers.map(layer => layer === parent ? alternate : layer) } + const candidate = { ...loaded, layers: loaded.layers.map(layer => layer === parent ? alternate : layer) } const afterRows = host.boot.composeEntries([host.boot.readProfilePatches('dsh', context, candidate)]) + // The only expected composition change is the carrier row's name: the standard + // mode row becomes the package root, whose entry applies that same mode. const expected = structuredClone(original) - flatten(expected).find(row => row.id === 'dsh-ops-file')!.name = 'dsh-ops' - if (!isDeepStrictEqual(afterRows, expected)) throw new Error('The optional layer or a higher-priority patch changes more than the carrier name; nothing was written.') + flatten(expected).find(row => row.id === 'dsh-ops-mode-standard')!.name = 'dsh-ops' + if (!isDeepStrictEqual(afterRows, expected)) throw new Error('The optional layer or a higher-priority patch changes more than the bundle name; nothing was written.') profile.bundles = bundles.map(name => name === 'dsh-ops' ? CLIENT_BUNDLE : name) - return { location, changedQualifiers, beforeRows: original, afterRows, + return { location, beforeRows: original, afterRows, files: [...(patchFile.text === undefined ? [] : [{ path: patchPath, before: patchFile.text, after: patchAfter, mode: patchFile.mode }]), { path: manifestPath, before: manifestFile.text, after: JSON.stringify(manifest, null, 2) + '\n', mode: manifestFile.mode }] } } diff --git a/src/settings/runtime-actions.ts b/src/settings/runtime-actions.ts index 6d695422..f18222d2 100644 --- a/src/settings/runtime-actions.ts +++ b/src/settings/runtime-actions.ts @@ -71,7 +71,7 @@ export class RuntimeActions { preview = { action, available: Boolean(pin) && !unowned.length, details: pin ? [ `Download ${pin.url}`, `Version: ${pin.version}; bytes: ${pin.bytes}; SHA-256: ${pin.sha256}`, `Install into ${provisionedShellVersionDir({ kind: 'bash', version: pin.version, env: this.options.env })}`, - 'The pinned archive and executable are verified. No profile setting is changed. Reload the shell row after selecting the executor.', + 'The pinned archive and executable are verified. No profile setting is changed. Reload the mode row after selecting the executor.', ...inventory.map(entry => 'Old managed version may be replaced: ' + entry.directory), ] : [], ...(!pin ? { reason: 'Managed Bash installation is available only for supported Windows builds.' } : {}), ...(unowned.length ? { reason: 'Unowned directories exist in the managed Bash store. Resolve them manually before installing.' } : {}) } diff --git a/test/bench-platform.test.mjs b/test/bench-platform.test.mjs index 9f549d51..a210ab4b 100644 --- a/test/bench-platform.test.mjs +++ b/test/bench-platform.test.mjs @@ -49,18 +49,18 @@ await test('compare CLI returns 2 for different platforms and requires an explic }) await test('composed component facts distinguish disabled, absent and unevaluated rows', () => { - const dump = '- id: dsh-ops-shell\n disabled: false\n- id: dsh-ops-discovery\n config:\n enabled: false\n- id: dsh-ops-panel\n disabled: process.platform !== "win32"\n' + const dump = '- id: dsh-ops-mode-standard\n disabled: false\n- id: dsh-ops-discovery\n config:\n enabled: false\n- id: dsh-ops-panel\n disabled: process.platform !== "win32"\n' const facts = componentFacts(dump) - assert.equal(facts.shell, 'enabled') + assert.equal(facts['mode-standard'], 'enabled') assert.equal(facts.discovery, 'disabled') assert.equal(facts.panel, 'unknown') - assert.equal(facts.ptc, 'absent') + assert.equal(facts.compat, 'absent') const arms = [{ side: 'base', profile: 'base' }, { side: 'cand', profile: 'cand' }] profileFingerprints(arms, { dump: () => dump }) assert.deepEqual(arms[0].components, facts) assert.deepEqual(arms[1].components, facts) - assert.equal(componentFacts('bad: [').shell, 'unknown') - assert.equal(componentFacts('- id: dsh-ops-shell\n- id: dsh-ops-shell\n').shell, 'unknown') + assert.equal(componentFacts('bad: [').compat, 'unknown') + assert.equal(componentFacts('- id: dsh-ops-mode-standard\n- id: dsh-ops-mode-standard\n')['mode-standard'], 'unknown') }) await test('Linux and macOS dry-run traverse setup without executing or writing profiles', async () => { @@ -86,9 +86,9 @@ await test('Linux and macOS dry-run traverse setup without executing or writing await test('host diagnostics cannot contaminate component configuration parsing', () => { const text = dumpProfileConfig({ kind: 'exe', file: 'fixture-dsh' }, 'bench', { run: () => ({ - status: 0, stdout: '- id: dsh-ops-shell\n', stderr: 'Warning: diagnostic, not configuration\n', + status: 0, stdout: '- id: dsh-ops-mode-standard\n', stderr: 'Warning: diagnostic, not configuration\n', }) }) - assert.equal(componentFacts(text).shell, 'enabled') + assert.equal(componentFacts(text)['mode-standard'], 'enabled') assert.doesNotMatch(text, /Warning/) }) diff --git a/test/bench-schedule.test.mjs b/test/bench-schedule.test.mjs index 3441ccf0..a4108adc 100644 --- a/test/bench-schedule.test.mjs +++ b/test/bench-schedule.test.mjs @@ -524,7 +524,7 @@ await test('main: a sweep is unsealed by default and --seal opts in', async () = resolveEntry: () => ENTRY, installSignals: false, now: NOW, - dumpProfileConfig: () => '- id: dsh-ops-shell\n disabled: false\n', + dumpProfileConfig: () => '- id: dsh-ops-mode-standard\n disabled: false\n', seal: () => { sealed += 1; return {} }, }) assert.equal(code, EXIT.ok) @@ -535,9 +535,9 @@ await test('main: a sweep is unsealed by default and --seal opts in', async () = const armDir = fs.readdirSync(group).find((name) => name.endsWith('bench-base')) assert.ok(fs.existsSync(path.join(group, armDir, 'summary.json')), 'the per-arm summary is the artifact the report reads') const summary = JSON.parse(fs.readFileSync(path.join(group, armDir, 'summary.json'), 'utf8')) - assert.equal(summary.components.shell, 'enabled', 'unsealed summaries still preserve component configuration') - assert.equal(summary.switches.shell.enabled, true) - assert.equal(summary.candidateSwitches.shell.enabled, true, 'both sides share the candidate conditions for pairing') + assert.equal(summary.components['mode-standard'], 'enabled', 'unsealed summaries still preserve component configuration') + assert.equal(summary.switches['mode-standard'].enabled, true) + assert.equal(summary.candidateSwitches['mode-standard'].enabled, true, 'both sides share the candidate conditions for pairing') assert.equal(summary.meta.platform, `${process.platform}-${process.arch}`) assert.equal(fs.existsSync(path.join(group, 'run.json')), false, 'nothing sealed this sweep') const reports = fs.readdirSync(group).filter((name) => name.startsWith('DSH-OPS Benchmark Report') && name.endsWith('.md')) diff --git a/test/bench-switches.test.mjs b/test/bench-switches.test.mjs index ae1136c8..b816ea18 100644 --- a/test/bench-switches.test.mjs +++ b/test/bench-switches.test.mjs @@ -11,35 +11,37 @@ import { main as compareMain, renderReport } from '../bench/compare.mjs' import { renderPanel } from '../bench/report/panel.mjs' import { switchesFromDump } from '../bench/lib/switches.mjs' -await test('switch parser validates repeatable names and replacement vocabulary', () => { - assert.deepEqual(extractSwitches(['--dry-run', '--switch', 'ptc=false', '--switch=hide-official=all']).switches, - { ptc: { enabled: false }, 'hide-official': { enabled: true, replaceTools: ['glob', 'grep', 'read'] } }) - for (const argv of [['--switch', 'oops'], ['--switch', 'ptc=maybe'], ['--switch', 'hide-official=write'], ['--switch', 'ptc', '--switch', 'ptc=false']]) assert.throws(() => extractSwitches(argv)) +await test('switch parser validates repeatable roster names and boolean values', () => { + assert.deepEqual(extractSwitches(['--dry-run', '--switch', 'mode-standard=false', '--switch=compat=true']).switches, + { 'mode-standard': { enabled: false }, compat: { enabled: true } }) + // The replacement vocabulary is gone: Compatibility is a plain row switch. + for (const argv of [['--switch', 'oops'], ['--switch', 'compat=maybe'], ['--switch', 'mode-standard=all'], ['--switch', 'hide-official=read'], ['--switch', 'mode-ptc', '--switch', 'mode-ptc=false']]) assert.throws(() => extractSwitches(argv)) }) await test('planning is idempotent and preserves foreign rows plus inherited component settings', () => { const before = '# preserve me\n- id: unrelated\n config:\n value: 9\n' - const switches = extractSwitches(['--switch', 'discovery', '--switch', 'hide-official=all']).switches + const switches = extractSwitches(['--switch', 'discovery', '--switch', 'mode-cordis']).switches const composed = [{ id: 'dsh-ops-discovery', config: { maxResults: 7, required: true, enabled: false } }] const plan = planSwitchPatch(before, switches, { composed }) assert.ok(plan.text.startsWith(before)) assert.deepEqual(parse(plan.text).find(row => row.id === 'dsh-ops-discovery').config, { maxResults: 7, required: true, enabled: true }) + assert.equal(parse(plan.text).find(row => row.id === 'dsh-ops-mode-cordis').disabled, false) assert.equal(planSwitchPatch(plan.text, switches, { composed }).changed, false) - assert.throws(() => planSwitchPatch('- id: dsh-ops-ptc\n disabled: true\n', { ptc: { enabled: true } }), /force/) - assert.equal(parse(planSwitchPatch('- id: dsh-ops-ptc\n disabled: true\n', { ptc: { enabled: true } }, { force: true }).text)[0].disabled, false) - assert.throws(() => planSwitchPatch('- id: dsh-ops-ptc\n- id: dsh-ops-ptc\n', { ptc: { enabled: true } }), /Duplicate/) + assert.throws(() => planSwitchPatch('- id: dsh-ops-mode-ptc\n disabled: true\n', { 'mode-ptc': { enabled: true } }), /force/) + assert.equal(parse(planSwitchPatch('- id: dsh-ops-mode-ptc\n disabled: true\n', { 'mode-ptc': { enabled: true } }, { force: true }).text)[0].disabled, false) + assert.throws(() => planSwitchPatch('- id: dsh-ops-mode-ptc\n- id: dsh-ops-mode-ptc\n', { 'mode-ptc': { enabled: true } }), /Duplicate/) }) await test('setup accepts switches in dry-run without launching a host or editing either profile', async () => { await withTempDir('switch-dry-', async dir => { const lines = [], example = path.join(dir, 'example.yml') fs.writeFileSync(example, '[]\n') - const code = await setup(['--dsh-home', dir, '--plugin', 'none', '--no-pin', '--no-permission-mode', '--dry-run', '--switch', 'hide-official=all'], { + const code = await setup(['--dsh-home', dir, '--plugin', 'none', '--no-pin', '--no-permission-mode', '--dry-run', '--switch', 'compat'], { entry: { file: 'fixture-dsh', kind: 'exe' }, overlay: path.join(dir, 'llm.yml'), overlayExample: example, invoke: () => { throw new Error('dry-run invoked host') }, out: line => lines.push(line), err: line => lines.push(line), }) assert.equal(code, 0, lines.join('\n')) - assert.match(lines.join('\n'), /candidate switches.*hide-official/) + assert.match(lines.join('\n'), /candidate switches.*compat/) assert.equal(fs.existsSync(path.join(dir, 'profiles')), false) }) }) @@ -56,23 +58,23 @@ await test('candidate preparation preserves baseline and refuses blocked or unpr return args.includes('--dump-config') ? { status: 0, stdout: fs.readFileSync(cand, 'utf8') } : { status: 0, stdout: '{"type":"status","phase":"turn_end","reason":{"kind":"completed"}}\n' } } } - configureSwitches(options, context, { ptc: { enabled: true } }) + configureSwitches(options, context, { 'mode-ptc': { enabled: true } }) assert.equal(fs.readFileSync(base, 'utf8'), '# baseline sentinel\n[]\n') assert.equal(calls.filter(args => args.includes('--json')).length, 1) const good = context.invoke for (const output of ['READINESS_BLOCKED', 'READY', '{"type":"turn_end","reason":"completed"}', '{"type":"status","phase":"turn_end","reason":{"kind":"failed"}}']) { context.invoke = args => args.includes('--dump-config') ? good(args) : { status: 0, stdout: output } - assert.throws(() => configureSwitches(options, context, { ptc: { enabled: true } }), /issues\/207/) + assert.throws(() => configureSwitches(options, context, { 'mode-ptc': { enabled: true } }), /issues\/207/) } }) }) await test('candidate switch conditions differ across sweeps, never across the two arms of one sweep', () => { - const switches = { ptc: { enabled: true } } + const switches = { 'mode-ptc': { enabled: true } } const base = { results: [], switches: {}, candidateSwitches: switches } const cand = { results: [], switches, candidateSwitches: switches } assert.doesNotThrow(() => compareSummaries(base, cand)) - assert.throws(() => compareSummaries(base, { ...cand, candidateSwitches: { ptc: { enabled: false } } }), /Candidate switches differ/) + assert.throws(() => compareSummaries(base, { ...cand, candidateSwitches: { 'mode-ptc': { enabled: false } } }), /Candidate switches differ/) assert.match(renderReport(compareSummaries(base, cand)), /Candidate switches/) }) @@ -84,8 +86,8 @@ await test('dynamic and non-mapping configuration is refused before candidate pl } }) -await test('panel and shell activation respect legacy switches and explicit overrides', () => { - for (const [name, key] of [['panel', 'capture'], ['shell', 'publishBashTool']]) { +await test('panel and discovery activation respect legacy switches and explicit overrides', () => { + for (const [name, key] of [['panel', 'capture'], ['discovery', 'enabled']]) { const before = `- id: dsh-ops-${name}\n disabled: false\n config:\n ${key}: false\n custom: 700\n` assert.equal(switchesFromDump(before)[name].enabled, false) assert.throws(() => planSwitchPatch(before, { [name]: { enabled: true } }), /force/) @@ -97,7 +99,7 @@ await test('panel and shell activation respect legacy switches and explicit over }) await test('unevaluated and malformed activation config remains unknown in archived facts', () => { - for (const name of ['panel', 'shell', 'discovery']) { + for (const name of ['panel', 'discovery']) { for (const config of ['!!js dynamic()', '[]', 'false']) { assert.equal(switchesFromDump(`- id: dsh-ops-${name}\n config: ${config}\n`)[name].enabled, null) } @@ -110,10 +112,10 @@ await test('official host composition retains inherited settings and applies onl const baseConfig = path.join(dir, 'base.yml'), candidatePatch = path.join(dir, 'candidate.yml') fs.writeFileSync(baseConfig, JSON.stringify([ { id: 'dsh-ops-panel', name: 'panel-fixture', disabled: true, config: { capture: false, maxFrames: 17 } }, - { id: 'dsh-ops-hide-official', name: 'hide-fixture', disabled: true, config: { replaceTools: [] } }, + { id: 'dsh-ops-compat', name: 'compat-fixture', disabled: true }, ])) const render = patches => renderConfigDump('dsh', baseConfig, patches, line => { throw new Error(line) }) - const baseline = render([]), wanted = extractSwitches(['--switch', 'panel', '--switch', 'hide-official=all']).switches + const baseline = render([]), wanted = extractSwitches(['--switch', 'panel', '--switch', 'compat']).switches const plan = planSwitchPatch('[]\n', wanted, { composed: parse(baseline) }) fs.writeFileSync(candidatePatch, plan.text) const candidate = render([{ label: 'candidate', patches: parse(fs.readFileSync(candidatePatch, 'utf8')) }]) @@ -126,8 +128,8 @@ await test('official host composition retains inherited settings and applies onl await test('CLI refuses differing candidate switches and the panel renders the archived set', async () => { await withTempDir('switch-report-', async dir => { const files = ['base', 'cand'].map(side => path.join(dir, `${side}.json`)) - const switches = { ptc: { enabled: true } }, lines = [] - for (const [index, file] of files.entries()) fs.writeFileSync(file, JSON.stringify({ format: 'dsh-ops-bench-summary-v1', results: [], candidateSwitches: { ptc: { enabled: index === 0 } } })) + const switches = { 'mode-ptc': { enabled: true } }, lines = [] + for (const [index, file] of files.entries()) fs.writeFileSync(file, JSON.stringify({ format: 'dsh-ops-bench-summary-v1', results: [], candidateSwitches: { 'mode-ptc': { enabled: index === 0 } } })) assert.equal(await compareMain([...files, '--out', '-'], { out: line => lines.push(line), err: line => lines.push(line) }), 2) assert.match(lines.join('\n'), /Candidate switches differ/) assert.match(lines.join('\n'), /"enabled":true/) @@ -142,7 +144,7 @@ await test('CLI refuses differing candidate switches and the panel renders the a try { await waitFor(() => { const failure = dom.window.document.getElementById('fallback-error').textContent assert.equal(failure, '') - return dom.window.document.body.textContent.includes('Candidate switches: {"ptc":{"enabled":true}}') + return dom.window.document.body.textContent.includes('Candidate switches: {"mode-ptc":{"enabled":true}}') }) } finally { dom.window.close() } }) diff --git a/test/bundle-startup.test.mjs b/test/bundle-startup.test.mjs index 263a0632..4d8560c1 100644 --- a/test/bundle-startup.test.mjs +++ b/test/bundle-startup.test.mjs @@ -1,4 +1,4 @@ -/** Shipped bundle + representative standard-preset fs/search plugins, real DSH registry/prompt/services. */ +/** Shipped eight-row bundle + representative standard-preset fs/search plugins, real DSH registry/prompt/services. */ import fs from 'node:fs' import path from 'node:path' import YAML from 'yaml' @@ -8,22 +8,54 @@ import FileSystem from '@deepseek-ai/dsh-fs-local' import * as FsTools from '@deepseek-ai/dsh-tool-fs' import * as SearchTools from '@deepseek-ai/dsh-tool-fs-search' import NodePtc from '@deepseek-ai/dsh-ptc-runtime-node' -import { createScope } from '@deepseek-ai/dsh-scope' +import { createScope, scopeOf, scopeParentOf } from '@deepseek-ai/dsh-scope' import { evaluate, isJsExpr } from '@deepseek-ai/cordis-plugin-loader' import { bootHost, shutdown } from './lib/host.mjs' import { assert, test, report, waitFor, withTempDir, PACKAGE_ROOT } from './lib/harness.mjs' import { resolveWorker } from '../lib/ptc/runtime.js' +import { modeSectionName, publicToolName } from '../lib/policy.js' const { parse } = YAML resolveWorker({}) // The shipped data-only bundle uses the public serialized Loader expression for platform defaults. const rows = parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'cordis.patch.yml'), 'utf8'))[0].insert const enabled = row => !(isJsExpr(row.disabled) ? evaluate({ process }, row.disabled.__jsExpr) : row.disabled) -assert.equal(rows.find(row => row.id === 'dsh-ops-hide-official').disabled, true) +assert.equal(rows.find(row => row.id === 'dsh-ops-compat').disabled, true) assert.equal(rows.find(row => row.id === 'dsh-ops-tui').disabled, true) +/** Mount the shipped enabled rows, each under its own component name. */ +async function mountBundle(ctx) { + for (const row of rows.filter(enabled)) { + const component = row.name.split('/')[1] + const module = await import(`../lib/components/${component}/index.js`) + await ctx.plugin(module, row.config) + } +} + +/** + * One real agent scope: fs/search official rows in the preset composition scope, + * a presentation, and a preset-identity stub. Official tools live in the preset + * parent scope exactly as the host composes a built-in preset; the agent scope + * inherits them, and an agent-scope restriction can only hide inherited names. + */ +async function mintAgent(ctx, presets, { id, mode, root, preset, presentation }) { + const presetScope = createScope(ctx, { preset: `${id}-preset` }).ctx + presets.set(scopeOf(presetScope), preset) + // These are the real standard-preset tool-fs and tool-fs-search rows, not fake + // official schema fixtures. No unrelated full-preset services. + await presetScope.plugin(FsTools, {}) + await presetScope.plugin(SearchTools, { sampleOverCapGlobResults: false }) + const agent = { id, session: { id, mode, header: { cwd: root } } } + // Create the agent scope from a tools-injected context: the scoped context + // inherits that dependency API, so per-agent restrictions are callable. + await presetScope.plugin({ inject: ['tools'], apply(inner) { agent.ctx = createScope(inner, agent, { parent: scopeOf(presetScope) }).ctx } }) + await agent.ctx.plugin({ inject: ['tools'], apply(inner) { inner.tools.presentAs(presentation) } }) + await ctx.serial('agent/created', { agent, source: 'startup' }) + return agent +} + for (const mode of ['read-only', 'workspace-write', 'danger-full-access']) { - await test(`default bundle starts with standard fs/search in native/PTC/both under ${mode}`, async () => { + await test(`default bundle publishes the matching preset surface with standard fs/search under ${mode}`, async () => { await withTempDir('bundle-startup', async root => { const { ctx } = await bootHost() try { @@ -32,60 +64,80 @@ for (const mode of ['read-only', 'workspace-write', 'danger-full-access']) { await ctx.plugin(FileSystem, {}) await ctx.plugin(inner => inner.provide('sandboxPolicy', { resolve: ({ session }) => ({ mode: session.mode, workspaceRoot: root, sessionId: session.id }) })) await ctx.plugin(NodePtc, {}) - for (const row of rows.filter(enabled)) { - const component = row.name === 'dsh-ops' ? 'file' : row.name.split('/')[1] - const module = await import(`../lib/components/${component}/index.js`) - const config = component === 'shell' && process.env.DSH_OPS_TEST_BASH - ? { ...row.config, bashPath: process.env.DSH_OPS_TEST_BASH } : row.config - await ctx.plugin(module, config) - } - for (const presentation of ['native', 'ptc', 'both']) { - const agent = { id: `${mode}-${presentation}`, session: { id: `${mode}-${presentation}`, mode, header: { cwd: root } } } - agent.ctx = createScope(ctx, agent).ctx - await agent.ctx.plugin({ inject: ['tools'], apply(inner) { inner.tools.presentAs(presentation) } }) - // These are the real standard-preset tool-fs and tool-fs-search rows, - // not fake official schema fixtures. No unrelated full-preset services. - await agent.ctx.plugin(FsTools, {}) - await agent.ctx.plugin(SearchTools, { sampleOverCapGlobResults: false }) - await ctx.serial('agent/created', { agent, source: 'startup' }) - const names = () => ctx.get('tools').schemas(agent).map(tool => tool.name) - await waitFor(() => names().includes(presentation === 'ptc' ? 'ops_inspect_local_file' : 'ops_read_files') && names().includes('read') && names().includes('grep') && - names().includes('ops_load_sdk') === (presentation === 'ptc') && - // The shell row is on by default on every supported platform (POSIX resolves the - // distribution's own bash); a configured-but-missing bash is covered below. - names().includes('ops_bash'), { what: `${mode}/${presentation} default publication` }) - const assembled = await ctx.get('systemPrompt').assemble({ scope: agent, signal: new AbortController().signal }) - assert.equal(assembled.tools.some(tool => tool.name === 'run_code'), presentation !== 'native') - assert.ok(assembled.sections.length > 0) - assert.ok(names().includes('write'), 'defaults must not mask standard-preset mutation tools') - const name = presentation === 'ptc' ? 'ops_inspect_local_file' : 'ops_read_files' - const args = presentation === 'ptc' ? { path: path.join(root, 'absent') } : { files: [{ path: path.join(root, 'absent') }] } - const result = await ctx.get('tools').get(name, agent).execute(args, { agent, signal: new AbortController().signal }) - const data = presentation === 'ptc' ? result.data : result - assert.equal(presentation === 'ptc' ? result.error.code : data.items[0].error.code, 'NOT_FOUND', JSON.stringify(result)) - console.log(` startup ${mode}/${presentation}: ${assembled.tools.length} wire tools; official + OPS intact`) - } + const presets = new Map() + // The real host exposes a preset registry; this stub answers for this + // fixture's scopes only, exactly as `composedPreset(agent.ctx)` does. + ctx.provide('agentPresets', { composedPreset: scoped => presets.get(scopeParentOf(scopeOf(scoped))) }) + await mountBundle(ctx) + const names = agent => ctx.get('tools').schemas(agent).map(tool => tool.name) + // `scope` drives the tool projection; route-section text additionally + // reads the live `agent`. + const assembled = agent => ctx.get('systemPrompt').assemble({ scope: agent, agent, signal: new AbortController().signal }) + + // A Standard-preset agent receives the resident native file tools, their + // discovery entry, and no replaced official file tools. + const standard = await mintAgent(ctx, presets, { id: `${mode}-standard`, mode, root, preset: 'standard', presentation: 'native' }) + await waitFor(() => names(standard).includes('ops_read_files') && names(standard).includes('ops_discover_tools'), { what: `${mode} standard publication` }) + for (const resident of ['ops_inspect_local_file', 'ops_glob', 'ops_grep', 'ops_read_files', 'ops_result']) assert.ok(names(standard).includes(resident), `${resident}: ${names(standard).join(', ')}`) + await waitFor(() => ['read', 'glob', 'grep'].every(name => !names(standard).includes(name)), { what: `${mode}: standard replacement hides official file tools` }) + assert.ok(names(standard).includes('write'), 'defaults must not mask standard-preset mutation tools') + assert.equal(names(standard).includes('ops_bash'), process.platform === 'win32', 'POSIX keeps the host bash; Windows replaces pwsh') + const standardPrompt = await assembled(standard) + assert.ok(standardPrompt.sections.some(section => section.name === modeSectionName('mode-standard') && String(section.text).includes('ops_read_files')), 'standard routing section names the resident surface') + const result = await ctx.get('tools').get('ops_read_files', standard).execute({ files: [{ path: path.join(root, 'absent') }] }, { agent: standard, signal: new AbortController().signal }) + assert.equal(result.items[0].error.code, 'NOT_FOUND', JSON.stringify(result)) + console.log(` startup ${mode}/standard: ${standardPrompt.tools.length} wire tools; resident OPS replaces read/glob/grep`) + + // A Cordis-preset agent is served by its own row, with its own section. + const cordis = await mintAgent(ctx, presets, { id: `${mode}-cordis`, mode, root, preset: 'cordis', presentation: 'native' }) + await waitFor(() => names(cordis).includes('ops_read_files'), { what: `${mode} cordis publication` }) + await waitFor(() => !names(cordis).includes('read'), { what: `${mode}: cordis replaces read` }) + const cordisPrompt = await assembled(cordis) + assert.ok(cordisPrompt.sections.some(section => section.name === modeSectionName('mode-cordis') && String(section.text).length > 0), 'cordis routing section') + + // The Minimal preset keeps its official tools and receives only the + // discovery entry. It publishes no routing section. + const minimal = await mintAgent(ctx, presets, { id: `${mode}-minimal`, mode, root, preset: 'minimal', presentation: 'native' }) + await waitFor(() => names(minimal).includes('ops_discover_tools'), { what: `${mode} minimal discovery entry` }) + for (const resident of ['ops_inspect_local_file', 'ops_read_files', 'ops_glob', 'ops_grep']) assert.equal(names(minimal).includes(resident), false, `${mode}: minimal publishes no ${resident}`) + for (const official of ['read', 'glob', 'grep']) assert.ok(names(minimal).includes(official), `${mode}: minimal keeps ${official}`) + const minimalPrompt = await assembled(minimal) + assert.equal(minimalPrompt.sections.some(section => section.name.startsWith('dsh-ops:repository-tooling:mode-') && String(section.text).trim() !== ''), false, 'minimal publishes no routing text') + + // A PTC-preset agent publishes the scoped SDK surface and no native + // routing text; official file tools stay visible (the PTC row replaces none). + const ptc = await mintAgent(ctx, presets, { id: `${mode}-ptc`, mode, root, preset: 'ptc', presentation: 'ptc' }) + await waitFor(() => names(ptc).includes('ops_load_sdk'), { what: `${mode} PTC publication` }) + assert.equal(names(ptc).includes('read'), true, 'the PTC row replaces no official file tool') + const ptcPrompt = await assembled(ptc) + assert.deepEqual(ptcPrompt.tools.map(tool => tool.name), ['run_code']) + assert.ok(ptcPrompt.sections.some(section => section.name === modeSectionName('mode-ptc') && String(section.text).length > 0), 'PTC routing section') + await waitFor(() => !names(ptc).includes('ops_discover_tools'), { what: `${mode} discovery withdrawal in PTC presentation` }) } finally { await shutdown(ctx) } }) }) } -await test('a missing optional Bash leaves the file bundle available', async () => { +await test('a missing optional Bash leaves the native file surface available', async () => { await withTempDir('bundle-no-bash', async root => { const { ctx } = await bootHost() try { await ctx.plugin(Subprocess) await ctx.plugin(Sandbox, {}) await ctx.plugin(inner => inner.provide('sandboxPolicy', { resolve: ({ session }) => ({ mode: session.mode, workspaceRoot: root, sessionId: session.id }) })) + const presets = new Map() + ctx.provide('agentPresets', { composedPreset: scoped => presets.get(scopeParentOf(scopeOf(scoped))) }) for (const row of rows.filter(enabled)) { - const component = row.name === 'dsh-ops' ? 'file' : row.name.split('/')[1] + const component = row.name.split('/')[1] const module = await import('../lib/components/' + component + '/index.js') - await ctx.plugin(module, component === 'shell' ? { ...row.config, bashPath: path.join(root, 'absent-bash.exe') } : row.config) + await ctx.plugin(module, component === 'mode-standard' ? { ...row.config, bashPath: path.join(root, 'absent-bash.exe') } : row.config) } + const presetScope = createScope(ctx, { preset: 'no-bash-preset' }).ctx + presets.set(scopeOf(presetScope), 'standard') const agent = { id: 'no-bash', session: { id: 'no-bash', mode: 'workspace-write', header: { cwd: root } } } - agent.ctx = createScope(ctx, agent).ctx + await presetScope.plugin({ inject: ['tools'], apply(inner) { agent.ctx = createScope(inner, agent, { parent: scopeOf(presetScope) }).ctx } }) await ctx.serial('agent/created', { agent, source: 'startup' }) const names = () => ctx.get('tools').schemas(agent).map(tool => tool.name) - await waitFor(() => names().includes('ops_read_files'), { what: 'file publication without optional Bash' }) + await waitFor(() => names().includes('ops_read_files'), { what: 'native file publication without optional Bash' }) assert.equal(names().includes('ops_bash'), false) const result = await ctx.get('tools').get('ops_read_files', agent).execute({ files: [{ path: path.join(root, 'absent') }] }, { agent, signal: new AbortController().signal }) assert.equal(result.items[0].status, 'error', JSON.stringify(result)) diff --git a/test/catalogue-consistency.test.mjs b/test/catalogue-consistency.test.mjs index 6376636d..be14aa16 100644 --- a/test/catalogue-consistency.test.mjs +++ b/test/catalogue-consistency.test.mjs @@ -53,8 +53,8 @@ await test('component declaration checks reject entry, target, tool, default and const failed = values => componentChecks(values).filter(([okay]) => !okay) assert.deepEqual(failed(rows), []) for (const [id, key, value] of [ - ['shell', 'entry', 'wrong'], ['file', 'targets', []], ['discovery', 'tools', []], - ['panel', 'tools', ['rogue']], ['tui', 'defaultEnabled', true], ['hide-official', 'retainedOfficialCommand', { win32: 'bash' }], + ['mode-minimal', 'entry', 'wrong'], ['mode-ptc', 'targets', []], ['discovery', 'tools', []], + ['panel', 'tools', ['rogue']], ['tui', 'defaultEnabled', true], ['compat', 'retainedOfficialCommand', { win32: 'bash' }], ]) { const changed = structuredClone(rows) changed.find(row => row.id === `dsh-ops-${id}`)[key] = value diff --git a/test/component-migration.test.mjs b/test/component-migration.test.mjs index 2b7d9f03..23de3013 100644 --- a/test/component-migration.test.mjs +++ b/test/component-migration.test.mjs @@ -1,26 +1,37 @@ import fs from 'node:fs' import path from 'node:path' import { tmpdir } from 'node:os' -import { parse } from 'yaml' import { assert, test, report, PACKAGE_ROOT, waitFor } from './lib/harness.mjs' import { componentProfile } from './lib/component-profile.mjs' +import { COMPONENTS, componentEntry } from '../lib/components/defaults.js' import { shutdown } from './lib/host.mjs' const current = fs.readFileSync(path.join(PACKAGE_ROOT, 'cordis.patch.yml'), 'utf8') -const previousRows = parse(current)[0].insert.filter(row => row.id !== 'dsh-ops-discovery') -for (const row of previousRows) { - if (['dsh-ops-shell', 'dsh-ops-panel'].includes(row.id)) delete row.disabled - if (row.id === 'dsh-ops-panel') row.config.capture = false -} -const previous = JSON.stringify([{ insert: previousRows }]) -for (const reversed of [false, true]) await test(`six-to-seven bundle refresh and restart preserve named overrides (reverse: ${reversed})`, async () => { - const choices = previousRows.map((row, index) => ({ id: row.id, name: row.name, disabled: Boolean(index % 2) !== reversed, +/** + * The 0.4.x legacy bundle fixture: seven independent rows, five of which were + * component modules. It is frozen here (not read from git history) because it is + * the input the migration has to keep supporting; the new bundle is the output. + */ +const LEGACY_ROWS = [ + { id: 'dsh-ops-shell', name: 'dsh-ops/shell', config: { promptPolicy: true, publishBashTool: true, shellPolicy: 'advise', allowSystemShellFallback: false } }, + { id: 'dsh-ops-file', name: 'dsh-ops/file', config: { promptPolicy: true, required: false } }, + { id: 'dsh-ops-ptc', name: 'dsh-ops/ptc', config: { required: false, alwaysLoaded: [] } }, + { id: 'dsh-ops-discovery', name: 'dsh-ops/discovery', config: { enabled: true, maxResults: 3, alwaysLoaded: [], required: false, denyCredentialReads: true } }, + { id: 'dsh-ops-panel', name: 'dsh-ops/panel', disabled: true, config: { capture: true, textLimit: 2000, maxSessionBytes: 8388608 } }, + { id: 'dsh-ops-tui', name: 'dsh-ops/tui', disabled: true, config: { maxSessions: 32, maxActive: 128, recentLimit: 8 } }, + { id: 'dsh-ops-hide-official', name: 'dsh-ops/hide-official', disabled: true, config: { replaceTools: [] } }, +] +const previous = JSON.stringify([{ insert: LEGACY_ROWS }]) +/** Shipped rows that kept their id and name, so a saved override still matches them. */ +const RETAINED = new Set(['dsh-ops-discovery', 'dsh-ops-panel', 'dsh-ops-tui']) +for (const reversed of [false, true]) await test(`legacy seven-row profile refreshes to the eight-component roster and preserves named overrides (reverse: ${reversed})`, async () => { + const choices = LEGACY_ROWS.map((row, index) => ({ id: row.id, name: row.name, disabled: Boolean(index % 2) !== reversed, config: { ...row.config, ...(row.id === 'dsh-ops-file' ? { extraGuidance: 'keep this exact file setting', configUpdateVersion: 2 } : {}), ...(row.id === 'dsh-ops-shell' ? { promptPolicy: false, configUpdateVersion: 2 } : {}), ...(row.id === 'dsh-ops-panel' ? { textLimit: 777, capture: false } : {}) } })) const foreign = { insert: [{ id: 'theme-sentinel', name: 'cordis:fixtureTheme', config: { preference: 'dark', fontSize: 17 } }] } const fixture = componentProfile(`migration-${reversed}`, previous, [...choices, foreign]) const original = fs.readFileSync(fixture.profile.patchPath, 'utf8') - const check = host => { + const checkLegacy = host => { for (const choice of choices) { const entry = host.entries().find(entry => entry.options.id === choice.id) assert.equal(entry.options.name, choice.name) @@ -32,17 +43,52 @@ for (const reversed of [false, true]) await test(`six-to-seven bundle refresh an assert.equal(fs.readFileSync(fixture.profile.patchPath, 'utf8'), original) assert.equal(fs.readFileSync(fixture.baseFile, 'utf8'), '[]\n') } + const checkNew = host => { + // Saved overrides keep their patch position ahead of the fresh bundle rows, so + // compare the composed set (and each row's specifier), not the registry order. + assert.deepEqual(host.entries().map(entry => entry.options.id).sort(), COMPONENTS.map(component => `dsh-ops-${component}`).sort()) + for (const component of COMPONENTS) { + assert.equal(host.entries().find(entry => entry.options.id === `dsh-ops-${component}`).options.name, componentEntry(component), component) + } + for (const choice of choices.filter(choice => RETAINED.has(choice.id))) { + const entry = host.entries().find(entry => entry.options.id === choice.id) + assert.equal(entry.disabled, choice.disabled, `${choice.id} saved on/off still matches its retained id`) + assert.deepEqual(entry.options.config, choice.config, `${choice.id} saved config still matches its retained id`) + } + // The removed rows' saved overrides are skipped, not fatal: startup still + // composes every shipped row. + for (const choice of choices.filter(choice => !RETAINED.has(choice.id))) { + assert.equal(host.entries().some(entry => entry.options.id === choice.id), false, `${choice.id} is no longer a shipped row`) + } + const theme = [...host.ctx.loader.entries()].find(entry => entry.options.id === 'theme-sentinel') + assert.deepEqual(theme.options.config, foreign.insert[0].config, 'foreign rows survive the roster change') + // The migration never rewrites the operator's saved overrides; the host may + // persist its own composed base file, which this suite does not pin. + assert.equal(fs.readFileSync(fixture.profile.patchPath, 'utf8'), original) + } let host = await fixture.boot('linux') try { - assert.equal(host.entries().length, 6); check(host) + assert.equal(host.entries().length, 7); checkLegacy(host) fs.writeFileSync(fixture.bundlePath, current) await host.refresh() - assert.equal(host.entries().length, 7); check(host) - assert.equal(host.entries().find(entry => entry.options.id === 'dsh-ops-discovery').disabled, false) - await host.refresh(); assert.equal(host.entries().length, 7); check(host) + assert.equal(host.entries().length, 8); checkNew(host) + // The fresh mode rows carry the bundle defaults, not a legacy row's config. + assert.equal(host.entries().find(entry => entry.options.id === 'dsh-ops-mode-standard').disabled, false) + assert.equal(host.entries().find(entry => entry.options.id === 'dsh-ops-compat').disabled, true) + await host.refresh(); assert.equal(host.entries().length, 8); checkNew(host) await shutdown(host.ctx) host = await fixture.boot('linux') - assert.equal(host.entries().length, 7); check(host) + assert.equal(host.entries().length, 8); checkNew(host) + } finally { await shutdown(host.ctx) } +}) +await test('a fresh profile composes the eight-component roster without a migration step', async () => { + const fixture = componentProfile('migration-fresh', current) + const host = await fixture.boot('linux') + try { + assert.deepEqual(host.entries().map(entry => entry.options.id), COMPONENTS.map(component => `dsh-ops-${component}`)) + assert.equal(host.entries().find(entry => entry.options.id === 'dsh-ops-mode-minimal').disabled, false) + assert.equal(host.entries().find(entry => entry.options.id === 'dsh-ops-tui').disabled, true) + assert.equal(fs.readFileSync(fixture.profile.patchPath, 'utf8'), '[]', 'startup does not write a composed tree') } finally { await shutdown(host.ctx) } }) await test('fresh panel row enablement starts collection while explicit legacy false stays inert', async () => { diff --git a/test/discovery-composition.test.mjs b/test/discovery-composition.test.mjs index c9dcd741..e0e29206 100644 --- a/test/discovery-composition.test.mjs +++ b/test/discovery-composition.test.mjs @@ -138,7 +138,7 @@ await test('same-scope PTC rows also use unique owned sections and restore the p const second = await mount('grep') await waitFor(() => names(ctx, owner).includes('ops_grep') && !names(ctx, owner).includes('ops_stat')) const assembled = await ctx.get('systemPrompt').assemble({ scope: owner }) - assert.equal(assembled.sections.filter(section => section.name.startsWith('dsh-ops:repository-tooling:ptc') && section.text).length, 1) + assert.equal(assembled.sections.filter(section => section.name.startsWith('dsh-ops:repository-tooling:mode-ptc') && section.text).length, 1) await second.dispose() await waitFor(() => names(ctx, owner).includes('ops_stat') && !names(ctx, owner).includes('ops_grep')) await first.dispose() diff --git a/test/host-metadata.test.mjs b/test/host-metadata.test.mjs index 90f3b9f0..ef16580d 100644 --- a/test/host-metadata.test.mjs +++ b/test/host-metadata.test.mjs @@ -3,6 +3,7 @@ import path from 'node:path' import { pathToFileURL, fileURLToPath } from 'node:url' import { parse } from 'yaml' import { composeBundle } from '../scripts/lib/compose-bundle.mjs' +import { COMPONENTS, componentEntry } from '../lib/components/defaults.js' import { assert, test, report, PACKAGE_ROOT } from './lib/harness.mjs' // The normal gate uses its locked host. An isolated published-host installation @@ -11,7 +12,9 @@ const boot = await import(process.env.DSH_OPS_TEST_HOST_BOOT ? pathToFileURL(path.resolve(process.env.DSH_OPS_TEST_HOST_BOOT)).href : '@deepseek-ai/dsh-app-boot') const pkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')) -const components = ['shell', 'file', 'background', 'ptc', 'discovery', 'panel', 'tui', 'hide-official'] +// The shipped roster is the code's own list: a component added to the plugin +// without resources, or left in the patch without a declaration, must not pass. +const components = [...COMPONENTS] const patch = parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'cordis.patch.yml'), 'utf8')) await test(`explicit host compatibility is consistent and bounded (${boot.getDshRuntimeVersion()})`, () => { @@ -42,9 +45,18 @@ await test('component resources resolve and the host reads bilingual text plus i } }) +await test('legacy component modules stay exported for saved rows but are no longer the shipped roster', () => { + for (const legacy of ['shell', 'file', 'ptc', 'hide-official', 'background']) { + assert.ok(pkg.exports[`./${legacy}`], `${legacy} legacy export must stay`) + assert.equal(components.includes(legacy), false, `${legacy} is no longer a shipped component`) + assert.equal(patch[0].insert.some((row) => row.id === `dsh-ops-${legacy}`), false, `${legacy} row must not be inserted`) + } +}) + await test('the host composes every shipped row and refuses a missing override target', () => { const rows = composeBundle([patch], boot.composeEntries) - assert.deepEqual(rows.map(row => row.id), components.filter(name => name !== 'background').map(name => `dsh-ops-${name}`)) + assert.deepEqual(rows.map(row => row.id), components.map(name => `dsh-ops-${name}`)) + assert.deepEqual(rows.map(row => row.name), components.map(componentEntry)) const upstream = [{ insert: [{ id: 'upstream', name: 'cordis:fixture' }] }] const override = [{ id: 'upstream', config: { enabled: true } }] assert.equal(composeBundle([upstream, patch, override], boot.composeEntries).find(row => row.id === 'upstream').config.enabled, true) diff --git a/test/lib/host.mjs b/test/lib/host.mjs index 7355f6fe..ace5b14b 100644 --- a/test/lib/host.mjs +++ b/test/lib/host.mjs @@ -44,21 +44,25 @@ export async function bootHost({ withTools = true, withSystemPrompt = true, keep } /** - * Load a fresh instance of the plugin module. + * Load a fresh instance of the legacy global file component. * + * The package root now publishes a per-preset mode instead of a global surface + * (it is the optional client-carrier row), so these helpers mount the retained + * legacy file module: its kernel semantics — global publication, read dedup, + * worker lifecycle — are what the shared fixtures below still verify. * A fresh module graph per mount keeps module-level state from leaking between * scenarios, at the cost of one import per call. * @param {string} [tag] - a cache-busting tag. * @returns {Promise<{apply: Function, name: string}>} the plugin module. */ export async function loadPlugin(tag = `${Date.now()}-${Math.random()}`) { - const file = pathToFileURL(path.join(PACKAGE_ROOT, 'lib', 'index.js')).href + const file = pathToFileURL(path.join(PACKAGE_ROOT, 'lib', 'components', 'file', 'index.js')).href return import(`${file}?tag=${tag}`) } /** - * Mount the plugin's legacy module entry (the file component) and wait until - * its tools appear (or the deadline passes). + * Mount the legacy file component and wait until its tools appear (or the + * deadline passes). * @param {object} options - mount inputs. * @param {Context} options.ctx - the host context. * @param {Record} [options.config] - the plugin row config. diff --git a/test/linux-scope.test.mjs b/test/linux-scope.test.mjs index adaf334f..962f4e88 100644 --- a/test/linux-scope.test.mjs +++ b/test/linux-scope.test.mjs @@ -148,8 +148,8 @@ await test('a foreign-only runtime directory is not a cross-platform substitute' await test('package.json, the resolver and the plugin manifest declare the same scope', async () => { const manifest = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')) const components = PLUGIN.contributes?.['x-components'] ?? [] - const ptc = components.find(component => component.id === 'dsh-ops-ptc') - assert.ok(ptc, 'the PTC component row is missing from contributes.x-components') + const ptc = components.find(component => component.id === 'dsh-ops-mode-ptc') + assert.ok(ptc, 'the PTC mode row is missing from contributes.x-components') assert.deepEqual([...PLATFORMS], ['win32-x64', 'linux-x64', 'darwin-arm64', 'darwin-x64'], 'only the approved target pairs are supported') assert.deepEqual(manifest.os, ['win32', 'linux', 'darwin']) assert.deepEqual(ptc.platforms, ['win32', 'linux', 'darwin']) diff --git a/test/manifest.test.mjs b/test/manifest.test.mjs index 1406f302..cfffe0f0 100644 --- a/test/manifest.test.mjs +++ b/test/manifest.test.mjs @@ -10,14 +10,15 @@ import fs from 'node:fs' import path from 'node:path' import { - HOST_SHELL_SECTION, - IN_PROCESS_TOOLS, + MODE_SECTION_ORDERS, TOOLING_SECTION, TOOL_PREFIX, + modeSectionName, publicToolName, renderToolingPolicy, } from '../lib/policy.js' import { NATIVE_FILE_TOOLS } from '../lib/capabilities/registry.js' +import { COMPONENTS, componentEnabled, componentEntry } from '../lib/components/defaults.js' import { assert, PACKAGE_ROOT, report, test } from './lib/harness.mjs' const manifestFile = path.join(PACKAGE_ROOT, 'dsh-plugin.json') @@ -75,7 +76,7 @@ await test('every declared tool is one the policy actually names', () => { assert.equal(namespaces.some((entry) => entry.serverName === 'fastctx' || entry.transport === 'stdio'), false) // Every further namespace is this plugin's own in-process surface, so its - // names are checked against `IN_PROCESS_TOOLS` and against the policy. + // names are checked against the shell capability and against the policy. for (const [offset, entry] of namespaces.slice(1).entries()) { const index = offset + 1 assert.ok( @@ -84,18 +85,12 @@ await test('every declared tool is one the policy actually names', () => { ) for (const tool of entry.tools) { assert.ok( - IN_PROCESS_TOOLS.includes(tool), + ['bash', 'pwsh'].includes(tool), `the namespace at index ${index} declares ${tool}, which the plugin does not register itself`, ) assert.ok(policy.includes(publicToolName(tool)), `the policy never names ${publicToolName(tool)}`) } } - - // The other direction: every tool this plugin registers itself is named - // somewhere, so a new in-process tool cannot land without policy text. - for (const tool of IN_PROCESS_TOOLS) { - assert.ok(policy.includes(publicToolName(tool)), `the policy never names ${publicToolName(tool)}`) - } }) await test('the declared namespace documents the upstream names and the public prefix', () => { @@ -110,9 +105,14 @@ await test('the declared namespace documents the upstream names and the public p } }) -await test('the declared prompt sections are the registered ones', () => { +await test('the declared prompt sections are the mode rows the policy registers', () => { const declared = raw.contributes['x-prompt-sections'].map((section) => section.name) - assert.deepEqual([...declared].sort(), ['file', 'shell', 'ptc'].map(name => `${TOOLING_SECTION}:${name}`).sort()) + const expected = Object.keys(MODE_SECTION_ORDERS).map((suffix) => modeSectionName(suffix)) + assert.deepEqual([...declared].sort(), [...expected].sort()) + // The legacy per-component sections are gone: rows publish routing text under + // their mode suffix only, and the Minimal row publishes none at all. + assert.equal(declared.some((name) => ['file', 'shell', 'ptc'].some((legacy) => name === `${TOOLING_SECTION}:${legacy}`)), false) + assert.equal(declared.includes(`${TOOLING_SECTION}:mode-minimal`), false) // The background stub still loads for existing rows but is no longer a declared component. assert.equal(raw.contributes['x-components'].some((component) => component.entry === 'dsh-ops/background'), false) }) @@ -128,17 +128,23 @@ await test('the manifest license matches the package and is MIT', () => { assert.ok(pkg.files.includes('licenses'), 'the license texts ship in the package') }) -await test('the bundle patch inserts no background row and writes no removed FastCtx key', async () => { +await test('the bundle patch inserts exactly the eight new rows and no removed FastCtx key', async () => { const { parse } = await import('yaml') const { LEGACY_FASTCTX_KEYS } = await import('../lib/config.js') const patch = parse(fs.readFileSync(path.join(PACKAGE_ROOT, pkg.dsh.bundle.patch), 'utf8')) const rows = patch.flatMap((entry) => entry.insert ?? []) - assert.deepEqual(rows.map((row) => row.name), ['dsh-ops/shell', 'dsh-ops/file', 'dsh-ops/ptc', 'dsh-ops/discovery', 'dsh-ops/panel', 'dsh-ops/tui', 'dsh-ops/hide-official']) + assert.deepEqual(rows.map((row) => row.id), COMPONENTS.map((component) => `dsh-ops-${component}`)) + assert.deepEqual(rows.map((row) => row.name), COMPONENTS.map((component) => componentEntry(component))) + // The legacy rows remain exported modules; they are not inserted into new profiles. + for (const legacy of ['shell', 'file', 'ptc', 'hide-official', 'background']) { + assert.equal(rows.some((row) => row.id === `dsh-ops-${legacy}`), false, `${legacy} must not be inserted`) + assert.equal(rows.some((row) => row.name === `dsh-ops/${legacy}`), false, `${legacy} specifier must not be inserted`) + } for (const row of rows) { for (const key of LEGACY_FASTCTX_KEYS) assert.equal(Object.hasOwn(row.config ?? {}, key), false, `${row.name} still writes ${key}`) } - // The stub stays exported so profiles that still name the row keep loading. - assert.ok(pkg.exports['./background']) + // Every legacy entry stays exported so profiles that still name the old row keep loading. + for (const legacy of ['shell', 'file', 'ptc', 'hide-official', 'background']) assert.ok(pkg.exports[`./${legacy}`], legacy) }) await test('the declared host entry is the shipped module', () => { @@ -171,7 +177,7 @@ await test('the card icon is a self-contained PNG with bounded dimensions', () = await test('the icon and all component entries are shipped in the package whitelist', () => { assert.ok(pkg.files.includes('docs/assets/icon.png')) - for (const component of ['shell', 'file', 'background', 'ptc', 'discovery', 'panel', 'tui', 'hide-official']) { + for (const component of COMPONENTS) { const directory = path.join(PACKAGE_ROOT, 'lib', 'components', component) assert.ok(fs.existsSync(path.join(directory, 'index.js'))) assert.ok(pkg.exports[`./${component}`]) @@ -184,10 +190,19 @@ await test('the icon and all component entries are shipped in the package whitel } }) -await test('hiding, panel and TUI are opt-in while shell/file/PTC/discovery rows default enabled', () => { +await test('the four mode rows and discovery are opt-in false only for compatibility, capture and TUI', () => { const components = raw.contributes['x-components'] - for (const [id, enabled] of [['shell', true], ['file', true], ['ptc', true], ['discovery', true], ['panel', false], ['tui', false], ['hide-official', false]]) { - assert.equal(components.find(component => component.id === `dsh-ops-${id}`).defaultEnabled, enabled) + assert.deepEqual(components.map((component) => component.id), COMPONENTS.map((component) => `dsh-ops-${component}`)) + for (const component of COMPONENTS) { + assert.equal(components.find((entry) => entry.id === `dsh-ops-${component}`).defaultEnabled, componentEnabled(component, 'win32')) + } + // Stated directly as well, so a flipped default is reviewed rather than + // silently accepted by the shared predicate. + for (const component of ['mode-minimal', 'mode-standard', 'mode-ptc', 'mode-cordis', 'discovery']) { + assert.equal(components.find((entry) => entry.id === `dsh-ops-${component}`).defaultEnabled, true, component) + } + for (const component of ['compat', 'panel', 'tui']) { + assert.equal(components.find((entry) => entry.id === `dsh-ops-${component}`).defaultEnabled, false, component) } }) diff --git a/test/modes.test.mjs b/test/modes.test.mjs new file mode 100644 index 00000000..d633ff42 --- /dev/null +++ b/test/modes.test.mjs @@ -0,0 +1,252 @@ +/** + * The four enhanced-preset mode rows, the Compatibility switch and the dynamic + * loading entry, on a real host. + * + * Every scenario boots the real Cordis host, the real tool/prompt registries and + * real registration scopes; only the preset roster is a stub, answering + * `composedPreset(agent.ctx)` the way the host's registry does. Official tools + * live in the preset composition scope (the agent scope's parent), which is the + * only place an agent-scope restriction may hide: the host refuses to hide a + * same-scope registration. + */ +import fs from 'node:fs' +import path from 'node:path' +import { createScope, scopeOf, scopeParentOf } from '@deepseek-ai/dsh-scope' +import Subprocess from '@deepseek-ai/dsh-subprocess-local' +import Sandbox from '@deepseek-ai/dsh-sandbox-local' +import * as Standard from '../lib/components/mode-standard/index.js' +import * as Cordis from '../lib/components/mode-cordis/index.js' +import * as Ptc from '../lib/components/mode-ptc/index.js' +import * as Compat from '../lib/components/compat/index.js' +import * as Discovery from '../lib/components/discovery/index.js' +import { BASH_TOOL, modeSectionName, publicToolName } from '../lib/policy.js' +import { MODE_PLANS, residentTools } from '../lib/modes/plan.js' +import { replacementTargets } from '../lib/modes/replacements.js' +import { WORKER } from './lib/boundary.mjs' +import { bootHost, shutdown, scopedToolNames } from './lib/host.mjs' +import { assert, test, report, skipSuite, wait, waitFor, withTempDir } from './lib/harness.mjs' + +const OFFICIAL = ['read', 'glob', 'grep', 'write'] +const RESIDENT = ['ops_inspect_local_file', 'ops_glob', 'ops_grep', 'ops_read_files', 'ops_result'] +const tool = name => ({ name, description: `fixture ${name}`, parameters: { type: 'object', properties: {} }, + output: { schema: { type: 'object' }, render: () => [] }, execute: async () => ({}) }) + +/** A scope that inherits the tools dependency API, so registrations and restrictions work. */ +async function scopedContext(parent, key, parentKey) { + let scoped + await parent.plugin({ inject: ['tools'], apply(inner) { scoped = createScope(inner, key, parentKey ? { parent: parentKey } : undefined).ctx } }) + return scoped +} + +/** One real host: services, a preset-identity stub, and helper accessors. */ +async function boot() { + const { ctx } = await bootHost() + await ctx.plugin(Subprocess) + await ctx.plugin(Sandbox, {}) + // The native file row's injection prerequisites; a missing service would leave + // it mounted but never activated, which looks exactly like a gating bug. + ctx.provide('sandboxPolicy', { resolve: ({ session }) => ({ mode: session.mode, workspaceRoot: session.header.cwd, sessionId: session.id }) }) + const presets = new Map() + ctx.provide('agentPresets', { composedPreset: scoped => presets.get(scopeParentOf(scopeOf(scoped))) }) + // The host's live-agent roster: reconcile passes relist it after a preset change. + const live = [] + ctx.provide('agents', { list: () => [...live] }) + const names = agent => scopedToolNames(ctx, agent) + const assemble = agent => ctx.get('systemPrompt').assemble({ scope: agent, agent, signal: new AbortController().signal }) + const sections = async agent => (await assemble(agent)).sections + let sequence = 0 + /** + * Mint one agent whose official tools live in the preset parent scope. + * @returns the live agent record. + */ + const mint = async ({ id = 'agent-' + ++sequence, preset, presentation, cwd }) => { + const presetScope = await scopedContext(ctx, { preset: `${id}-preset` }) + presets.set(scopeOf(presetScope), preset) + for (const name of OFFICIAL) presetScope.tools.register(tool(name)) + const agent = { id, session: { id, mode: 'danger-full-access', header: { cwd } } } + agent.ctx = await scopedContext(presetScope, agent, scopeOf(presetScope)) + if (presentation) await agent.ctx.plugin({ inject: ['tools'], apply(inner) { inner.tools.presentAs(presentation) } }) + live.push(agent) + await ctx.serial('agent/created', { agent, source: 'startup' }) + return agent + } + const before = () => ctx.get('tools') + return { ctx, presets, names, assemble, sections, mint, tools: before } +} + +/** The activation targets are pure data; assert them without a host or worker. */ +await test('replacement targets map each mode to the tools it hides, platform by platform', () => { + const win = replacementTargets('standard', 'win32') + assert.equal(win.get('read'), publicToolName('inspect_local_file')) + assert.equal(win.get('glob'), publicToolName('glob')) + assert.equal(win.get('grep'), publicToolName('grep')) + assert.equal(win.get('pwsh'), BASH_TOOL) + assert.equal(win.get('pwsh_persistent'), BASH_TOOL) + assert.equal(win.has('write'), false, 'write has no reviewed replacement') + const linux = replacementTargets('standard', 'linux') + assert.equal(linux.has('pwsh'), false, 'POSIX keeps the host bash') + assert.equal(replacementTargets('cordis', 'win32').get('read'), publicToolName('inspect_local_file')) + // Minimal and PTC replace only the Windows shell; their file tools stay official. + for (const mode of ['minimal', 'ptc']) { + const targets = replacementTargets(mode, 'win32') + assert.deepEqual([...targets.keys()].sort(), ['pwsh', 'pwsh_persistent'].sort(), mode) + assert.equal([...targets.values()].every(value => value === BASH_TOOL), true, mode) + assert.equal(replacementTargets(mode, 'linux').size, 0, `${mode} replaces nothing on POSIX`) + } + assert.deepEqual(residentTools('standard'), RESIDENT) + assert.deepEqual(residentTools('cordis'), RESIDENT) + assert.deepEqual(residentTools('minimal'), []) + assert.deepEqual(residentTools('ptc'), []) + assert.equal(MODE_PLANS.minimal.section, null) + assert.equal(MODE_PLANS.ptc.section, 'ptc') +}) + +if (!fs.existsSync(WORKER)) skipSuite('modes', `no native worker at ${path.relative(process.cwd(), WORKER)}; run npm run build:ptc`) + +await test('mode-standard publishes resident OPS tools only for its preset and hides its official counterparts', () => withTempDir('mode-standard', async cwd => { + const { ctx, names, assemble, mint } = await boot() + try { + const standard = await ctx.plugin(Standard, { required: true, promptPolicy: true }) + const own = await mint({ preset: 'standard', cwd }) + await waitFor(() => names(own).includes('ops_read_files'), { what: 'standard resident publication' }) + for (const name of RESIDENT) assert.ok(names(own).includes(name), `${name} in ${names(own).join(', ')}`) + assert.ok(names(own).includes('write'), 'unreplaced official mutation tools stay visible') + await waitFor(() => OFFICIAL.filter(name => name !== 'write').every(name => !names(own).includes(name)), { what: 'official read/glob/grep hidden' }) + assert.equal(names(own).includes('ops_bash'), process.platform === 'win32', 'the Windows shell replacement publishes ops_bash') + const section = (await assemble(own)).sections.find(entry => entry.name === modeSectionName('mode-standard')) + assert.ok(section && String(section.text).includes('ops_read_files'), 'the mode-standard routing section names the surface') + // A different preset with no mode row mounted sees no OPS surface at all and + // keeps every official tool. + const other = await mint({ preset: 'cordis', cwd }) + await wait(300) + assert.deepEqual(names(other).filter(name => name.startsWith('ops_')), [], 'an unenhanced preset receives no OPS tools') + for (const name of OFFICIAL) assert.ok(names(other).includes(name), `${name} unchanged for the unenhanced preset`) + const foreignSections = (await assemble(other)).sections.filter(entry => entry.name.startsWith('dsh-ops:repository-tooling:mode-')) + assert.equal(foreignSections.some(entry => String(entry.text).trim() !== ''), false, 'no mode routing text leaks to another preset') + await standard.dispose() + } finally { await shutdown(ctx) } +})) + +await test('compatibility mode restores every official tool while the OPS surface stays published', () => withTempDir('mode-compat', async cwd => { + const { ctx, names, mint } = await boot() + try { + await ctx.plugin(Standard, { required: true, promptPolicy: true }) + const agent = await mint({ preset: 'standard', cwd }) + await waitFor(() => names(agent).includes('ops_read_files') && !names(agent).includes('read'), { what: 'replacement applied' }) + const compat = await ctx.plugin(Compat, {}) + await waitFor(() => OFFICIAL.filter(name => name !== 'write').every(name => names(agent).includes(name)), { what: 'compatibility restores official file tools' }) + for (const name of RESIDENT) assert.ok(names(agent).includes(name), `compatibility never unloads ${name}`) + await compat.dispose() + await waitFor(() => OFFICIAL.filter(name => name !== 'write').every(name => !names(agent).includes(name)), { what: 're-hide after compatibility is off' }) + for (const name of RESIDENT) assert.ok(names(agent).includes(name), `re-hide never unloads ${name}`) + } finally { await shutdown(ctx) } +})) + +await test('disposing a mode row withdraws both its OPS surface and its replacements', () => withTempDir('mode-dispose', async cwd => { + const { ctx, names, mint } = await boot() + try { + const row = await ctx.plugin(Standard, { required: true, promptPolicy: true }) + const agent = await mint({ preset: 'standard', cwd }) + await waitFor(() => names(agent).includes('ops_read_files') && !names(agent).includes('read'), { what: 'mode applied' }) + await row.dispose() + await waitFor(() => RESIDENT.every(name => !names(agent).includes(name)), { what: 'resident tools withdrawn with the row' }) + await waitFor(() => OFFICIAL.every(name => names(agent).includes(name)), { what: 'official tools restored with the row' }) + assert.equal(names(agent).includes('ops_bash'), false, 'the Windows shell replacement is withdrawn too') + } finally { await shutdown(ctx) } +})) + +await test('mode-cordis serves its own preset with the same resident surface and section', () => withTempDir('mode-cordis', async cwd => { + const { ctx, names, sections, mint } = await boot() + try { + const row = await ctx.plugin(Cordis, { required: true, promptPolicy: true }) + const agent = await mint({ preset: 'cordis', cwd }) + await waitFor(() => names(agent).includes('ops_read_files') && !names(agent).includes('read'), { what: 'cordis mode applied' }) + for (const name of RESIDENT) assert.ok(names(agent).includes(name), name) + assert.ok((await sections(agent)).some(section => section.name === modeSectionName('mode-cordis') && String(section.text).includes('ops_read_files')), 'cordis routing section') + // The Standard row is not mounted here, so a Standard-preset agent stays untouched. + const standard = await mint({ preset: 'standard', cwd }) + await wait(300) + assert.deepEqual(names(standard).filter(name => name.startsWith('ops_')), []) + for (const name of OFFICIAL) assert.ok(names(standard).includes(name), name) + await row.dispose() + } finally { await shutdown(ctx) } +})) + +await test('dynamic loading serves Minimal always and enabled modes, and never PTC-only presentation', () => withTempDir('mode-discovery', async cwd => { + const { ctx, names, mint } = await boot() + try { + await ctx.plugin(Discovery, { required: true }) + // The Minimal preset always receives the entry, with no mode-minimal row mounted. + const minimal = await mint({ preset: 'minimal', cwd }) + await waitFor(() => names(minimal).includes('ops_discover_tools'), { what: 'minimal discovery entry' }) + assert.deepEqual(names(minimal).filter(name => name.startsWith('ops_')), ['ops_discover_tools']) + for (const name of OFFICIAL) assert.ok(names(minimal).includes(name), `minimal keeps ${name}`) + // A Standard agent receives it as well once its mode row is enabled, and the + // resident names are not offered as discovery choices. + const standardRow = await ctx.plugin(Standard, { required: true, promptPolicy: true }) + const standard = await mint({ preset: 'standard', cwd }) + await waitFor(() => names(standard).includes('ops_read_files') && names(standard).includes('ops_discover_tools'), { what: 'standard discovery entry' }) + const discover = names(standard).includes('ops_discover_tools') && ctx.get('tools').get('ops_discover_tools', standard) + const residentChoice = await discover.execute({ query: 'select:ops_glob' }, { callId: 'discover', name: 'ops_discover_tools', arguments: {}, signal: new AbortController().signal, agent: standard }) + assert.deepEqual(residentChoice.activated, [], 'a resident mode tool is not a discovery choice') + const advancedChoice = await discover.execute({ query: 'select:ops_stat' }, { callId: 'discover-2', name: 'ops_discover_tools', arguments: {}, signal: new AbortController().signal, agent: standard }) + assert.deepEqual(advancedChoice.activated, [publicToolName('stat')], 'an advanced capability stays discoverable') + // An unenhanced PTC-preset agent in PTC-only presentation gets no entry. + const ptcAgent = await mint({ preset: 'ptc', presentation: 'ptc', cwd }) + await wait(400) + assert.equal(names(ptcAgent).includes('ops_discover_tools'), false, 'PTC-only presentation is not a discovery population') + await standardRow.dispose() + } finally { await shutdown(ctx) } +})) + +await test('PTC mode publishes the scoped SDK surface and replaces only the Windows shell', () => withTempDir('mode-ptc', async cwd => { + const { ctx, names, mint } = await boot() + try { + const NodePtc = (await import('@deepseek-ai/dsh-ptc-runtime-node')).default + const FileSystem = (await import('@deepseek-ai/dsh-fs-local')).default + // The PTC runtime injects fs/subprocess/sandbox/sandboxPolicy; without fs it + // never activates and the row's presentation probe sees no ptcRuntime. + await ctx.plugin(FileSystem, {}) + await ctx.plugin(NodePtc, {}) + const row = await ctx.plugin(Ptc, { required: true }) + const agent = await mint({ preset: 'ptc', presentation: 'ptc', cwd }) + await waitFor(() => names(agent).includes('ops_load_sdk'), { what: 'PTC SDK publication' }) + for (const name of OFFICIAL) assert.ok(names(agent).includes(name), `PTC replaces no official file tool; ${name} stays visible`) + assert.equal(names(agent).includes('ops_bash'), process.platform === 'win32', 'only the Windows shell is replaced') + // A Standard-preset agent is not served by the PTC row. + const standard = await mint({ preset: 'standard', cwd }) + await wait(300) + assert.equal(names(standard).includes('ops_load_sdk'), false) + await row.dispose() + } finally { await shutdown(ctx) } +})) + +await test('a preset selection change withdraws the old mode surface and the new row takes over', () => withTempDir('mode-switch', async cwd => { + const { ctx, presets, names, sections, mint } = await boot() + try { + await ctx.plugin(Standard, { required: true, promptPolicy: true }) + await ctx.plugin(Cordis, { required: true, promptPolicy: true }) + const agent = await mint({ preset: 'standard', cwd }) + await waitFor(() => names(agent).includes('ops_read_files') && !names(agent).includes('read'), { what: 'standard mode applied' }) + assert.ok((await sections(agent)).some(section => section.name === modeSectionName('mode-standard') && String(section.text).length > 0)) + // A blank session may select another preset before its first turn. + presets.set(scopeParentOf(scopeOf(agent.ctx)), 'cordis') + await ctx.serial('session/event', agent.session, { type: 'agent-preset/selected' }) + const waitForSection = async (name, what) => { + for (let attempt = 0; attempt < 100; attempt++) { + if ((await sections(agent)).some(section => section.name === name && String(section.text).length > 0)) return + await wait(50) + } + throw new Error(`timed out waiting for ${what}`) + } + await waitFor(() => !names(agent).includes('read'), { what: 'cordis mode takes over the same scope' }) + await waitForSection(modeSectionName('mode-cordis'), 'the cordis routing section') + assert.ok(names(agent).includes('ops_read_files'), 'the new matching row publishes the resident surface') + assert.equal(names(agent).includes('read'), false, 'the replaced official tool stays hidden under the new mode') + presets.set(scopeParentOf(scopeOf(agent.ctx)), 'standard') + await ctx.serial('session/event', agent.session, { type: 'agent-preset/selected' }) + await waitForSection(modeSectionName('mode-standard'), 'the standard routing section to return') + } finally { await shutdown(ctx) } +})) + +report('modes') diff --git a/test/official-tool-hiding.test.mjs b/test/official-tool-hiding.test.mjs index 1e1d3824..d9be45fb 100644 --- a/test/official-tool-hiding.test.mjs +++ b/test/official-tool-hiding.test.mjs @@ -50,14 +50,19 @@ await test('the real Cordis loader consumes the component Standard Schema valida assert.match(error.issues[0].message, /unsupported tool: "write"/) } finally { await ctx.fiber.dispose() } }) -await test('the disabled component patch also defaults to an empty selection', () => { +await test('the disabled Compatibility row is the shipped replacement default', () => { const document = parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'cordis.patch.yml'), 'utf8'), { customTags: [{ tag: 'tag:yaml.org,2002:js', resolve: value => value }], }) - const hiding = document.flatMap(item => item.insert).find(row => row.name === 'dsh-ops/hide-official') - assert.equal(hiding.disabled, true) - assert.deepEqual(hiding.config, { replaceTools: [] }) - assert.deepEqual(resolveHidingConfig(hiding.config), { replaceTools: [] }) + const rows = document.flatMap(item => item.insert) + // The legacy hide-official row is no longer inserted; Compatibility is one + // switch, so the bundle must not carry a per-tool replacement selection. + assert.equal(rows.some(row => row.name === 'dsh-ops/hide-official'), false) + const compat = rows.find(row => row.id === 'dsh-ops-compat') + assert.ok(compat, 'the Compatibility row is missing') + assert.equal(compat.disabled, true, 'Compatibility is off by default') + assert.equal(Object.hasOwn(compat.config ?? {}, 'replaceTools'), false, 'the bundle selects no per-tool replacements') + assert.deepEqual(resolveHidingConfig({ replaceTools: [] }), { replaceTools: [] }, 'the retained legacy validator still resolves an empty selection') for (const platform of ['win32', 'linux', 'darwin']) { assert.deepEqual(platformSurface(platform), { retainedCommand: platform === 'win32' ? 'pwsh' : 'bash', shellDefaultEnabled: true }) } diff --git a/test/package-source.test.mjs b/test/package-source.test.mjs index 39e3bba2..0ab86f02 100644 --- a/test/package-source.test.mjs +++ b/test/package-source.test.mjs @@ -15,7 +15,12 @@ await test('npm pack includes compiled host code and native sources, never host const files = JSON.parse(result.stdout)[0].files.map(file => file.path) const required = ['lib/ptc/sdk.d.ts', 'lib/index.js', 'lib/policy.js', 'lib/components/panel/index.js', 'lib/components/panel/package.json', 'lib/components/panel/icon.png', 'lib/panel/panel.html', - 'lib/components/tui/dsh-plugin.json', 'native/Cargo.toml', 'native/Cargo.lock', 'native/src/lib.rs', + 'lib/components/tui/dsh-plugin.json', + 'lib/components/mode-minimal/index.js', 'lib/components/mode-minimal/package.json', 'lib/components/mode-minimal/icon.png', + 'lib/components/mode-standard/index.js', 'lib/components/mode-standard/locale/en.json', 'lib/components/mode-standard/locale/zh.json', + 'lib/components/mode-ptc/index.js', 'lib/components/mode-cordis/index.js', 'lib/components/compat/index.js', + 'lib/modes/plan.js', 'lib/modes/apply.js', 'lib/modes/detect.js', 'lib/modes/replacements.js', + 'native/Cargo.toml', 'native/Cargo.lock', 'native/src/lib.rs', 'native/windows-metadata/Cargo.toml', 'native/windows-metadata/src/lib.rs', 'native/darwin-metadata/Cargo.toml', 'native/darwin-metadata/src/lib.rs', 'native/crates/ops-documents/Cargo.toml', 'native/crates/ops-documents/src/lib.rs', diff --git a/test/platform-defaults.test.mjs b/test/platform-defaults.test.mjs index c5c9013f..2e33879d 100644 --- a/test/platform-defaults.test.mjs +++ b/test/platform-defaults.test.mjs @@ -3,49 +3,60 @@ import path from 'node:path' import { parse } from 'yaml' import { assert, test, report, PACKAGE_ROOT } from './lib/harness.mjs' import { platformSurface } from '../lib/official-tool-catalog.js' -import { COMPONENTS, componentEnabled } from '../lib/components/defaults.js' +import { COMPONENTS, componentEnabled, componentEntry } from '../lib/components/defaults.js' import { componentProfile } from './lib/component-profile.mjs' import { shutdown } from './lib/host.mjs' import { PTC_LOADABLE_TOOLS } from '../lib/ptc/config.js' +import { resolveModeConfig } from '../lib/modes/config.js' const patch = fs.readFileSync(path.join(PACKAGE_ROOT, 'cordis.patch.yml'), 'utf8') const rows = parse(patch).flatMap(entry => entry.insert ?? []) const manifest = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'dsh-plugin.json'), 'utf8')) -await test('seven stable row defaults and manifest agree on every platform', () => { - assert.equal(rows.length, 7) - assert.equal(rows.find(row => row.id === 'dsh-ops-shell').disabled, undefined, 'the shell row is enabled by default on every platform') +await test('eight stable row defaults and manifest agree on every platform', () => { + assert.equal(rows.length, 8) + assert.deepEqual(rows.map(row => row.id), COMPONENTS.map(component => `dsh-ops-${component}`)) for (const component of COMPONENTS) { const row = rows.find(row => row.id === `dsh-ops-${component}`) assert.ok(row) - assert.equal(row.name, `dsh-ops/${component}`, 'legacy file name-qualified overrides must keep matching') + assert.equal(row.name, componentEntry(component), 'the row specifier comes from the exported entry') const declared = manifest.contributes['x-components'].find(item => item.id === row.id) for (const platform of ['win32', 'linux', 'darwin']) { assert.equal(declared.defaultEnabled, componentEnabled(component, platform)) assert.equal(row.disabled ?? false, !componentEnabled(component, platform)) } } + // The legacy component modules are no longer inserted; a saved row keeps them + // loadable, but a fresh profile must not compose them. + for (const legacy of ['shell', 'file', 'ptc', 'hide-official', 'background']) { + assert.equal(rows.some(row => row.id === `dsh-ops-${legacy}`), false, legacy) + } for (const platform of ['win32', 'linux', 'darwin']) assert.equal(platformSurface(platform).shellDefaultEnabled, true) }) -for (const platform of ['win32', 'linux', 'darwin']) await test(`real Loader applies fresh seven-row ${platform} defaults`, async () => { +for (const platform of ['win32', 'linux', 'darwin']) await test(`real Loader applies fresh eight-row ${platform} defaults`, async () => { const fixture = componentProfile(`defaults-${platform}`, patch) const { ctx, entries } = await fixture.boot(platform) try { - assert.equal(entries().length, 7) + assert.equal(entries().length, 8) for (const entry of entries()) assert.equal(!entry.disabled, componentEnabled(entry.options.id.slice('dsh-ops-'.length), platform), entry.options.id) assert.equal(entries().find(entry => entry.options.id === 'dsh-ops-panel').options.config.capture, true) - const ptc = entries().find(entry => entry.options.id === 'dsh-ops-ptc') - assert.deepEqual(ptc.fiber.config.allowedTools, PTC_LOADABLE_TOOLS, 'fresh PTC pool follows the implemented catalogue') - assert.ok(ptc.fiber.config.allowedTools.includes('read_table')) + // Row configs are validated by the mode converter, not mirrored on the fiber. + const ptc = resolveModeConfig(entries().find(entry => entry.options.id === 'dsh-ops-mode-ptc').options.config) + assert.deepEqual(ptc.allowedTools, PTC_LOADABLE_TOOLS, 'fresh mode-ptc pool follows the implemented catalogue') + assert.ok(ptc.allowedTools.includes('read_table')) + const standard = resolveModeConfig(entries().find(entry => entry.options.id === 'dsh-ops-mode-standard').options.config) + assert.equal(standard.promptPolicy, true) + const compat = entries().find(entry => entry.options.id === 'dsh-ops-compat') + assert.equal(compat.disabled, true, 'Compatibility stays off until the operator opts in') assert.equal(fs.readFileSync(fixture.profile.patchPath, 'utf8'), '[]', 'startup does not write a composed tree') } finally { await shutdown(ctx) } }) await test('saved explicit PTC pools survive bundle refresh and a fresh profile boot', async () => { - const choices = [{ id: 'dsh-ops-ptc', name: 'dsh-ops/ptc', config: { allowedTools: ['stat'], alwaysLoaded: [] } }] + const choices = [{ id: 'dsh-ops-mode-ptc', name: 'dsh-ops/mode-ptc', config: { allowedTools: ['stat'], alwaysLoaded: [] } }] const fixture = componentProfile('defaults-saved-ptc', patch, choices) const original = fs.readFileSync(fixture.profile.patchPath, 'utf8') let host = await fixture.boot('linux') const check = () => { - const entry = host.entries().find(value => value.options.id === 'dsh-ops-ptc') - assert.deepEqual(entry.fiber.config.allowedTools, ['stat']) + const entry = host.entries().find(value => value.options.id === 'dsh-ops-mode-ptc') + assert.deepEqual(resolveModeConfig(entry.options.config).allowedTools, ['stat']) assert.equal(fs.readFileSync(fixture.profile.patchPath, 'utf8'), original) } try { @@ -53,4 +64,16 @@ await test('saved explicit PTC pools survive bundle refresh and a fresh profile await shutdown(host.ctx); host = await fixture.boot('linux'); check() } finally { await shutdown(host.ctx) } }) +await test('the shipped mode configs are what the validators resolve', () => { + for (const mode of ['mode-minimal', 'mode-standard', 'mode-ptc', 'mode-cordis']) { + const row = rows.find(row => row.id === `dsh-ops-${mode}`) + const resolved = resolveModeConfig(row.config) + assert.deepEqual(row.config.alwaysLoaded ?? [], resolved.alwaysLoaded, mode) + } + // Minimal publishes no routing text, so promptPolicy is its only routing key; + // the native modes carry the shared file resolver settings. + const minimal = resolveModeConfig(rows.find(row => row.id === 'dsh-ops-mode-minimal').config) + assert.equal(minimal.required, false) + assert.equal(minimal.publishBashTool, true) +}) report('platform-defaults') diff --git a/test/ptc.test.mjs b/test/ptc.test.mjs index af22601c..07d108bd 100644 --- a/test/ptc.test.mjs +++ b/test/ptc.test.mjs @@ -248,7 +248,7 @@ try { const assembled = await ctx.get('systemPrompt').assemble({scope:pure}) assert.deepEqual(assembled.tools.map(t => t.name), ['run_code']) assert.ok(assembled.sections.some(s => s.text.includes('ops_batch'))) - assert.ok(assembled.sections.some(s => s.name === 'dsh-ops:repository-tooling:ptc' && s.text.includes('typed Outcome'))) + assert.ok(assembled.sections.some(s => s.name === 'dsh-ops:repository-tooling:mode-ptc' && s.text.includes('typed Outcome'))) assert.equal((await ctx.get('systemPrompt').assemble({scope:native})).sections.some(s => s.text.includes('ops_batch')), false) const def = ctx.get('tools').get('ops_batch', pure) const wrongOwner = await def.execute({ action: 'plan', operations: [] }, { agent: ptcChild, signal: new AbortController().signal }) diff --git a/test/release-scope.test.mjs b/test/release-scope.test.mjs index f792c2ff..f0e3bbf6 100644 --- a/test/release-scope.test.mjs +++ b/test/release-scope.test.mjs @@ -74,7 +74,7 @@ await test('half-staged and unexpected targets are never silently skipped',async })) await test('manifest exact targets, npm platform filters and shell pins must agree',async()=>withTempDir('release-drift',root=>{ fixture(root); const file=path.join(root,'dsh-plugin.json'), manifest=JSON.parse(fs.readFileSync(file,'utf8')) - manifest.contributes['x-components'].find(c=>c.id==='dsh-ops-ptc').targets.push('win32-arm64') + manifest.contributes['x-components'].find(c=>c.id==='dsh-ops-mode-ptc').targets.push('win32-arm64') fs.writeFileSync(file,JSON.stringify(manifest)); assert.match(run(root).out,/disagree/) })) await test('release closure requires the pinned Bash payload and refuses a PowerShell dependency', async () => withTempDir('release-shell-dep', root => { diff --git a/test/settings-carrier.test.mjs b/test/settings-carrier.test.mjs index 03ccaa0c..b2fccc4b 100644 --- a/test/settings-carrier.test.mjs +++ b/test/settings-carrier.test.mjs @@ -8,6 +8,7 @@ import * as atomic from '@deepseek-ai/dsh-atomic-write' import { ClientModuleRegistry } from '@deepseek-ai/dsh-client-modules' import { carrierPreview, applyCarrier } from '../lib/settings/carrier-write.js' import { CLIENT_BUNDLE, profileContext } from '../lib/settings/carrier-plan.js' +import { COMPONENTS } from '../lib/components/defaults.js' import { componentProfile } from './lib/component-profile.mjs' import { shutdown } from './lib/host.mjs' import { assert, test, report, PACKAGE_ROOT, waitFor } from './lib/harness.mjs' @@ -18,18 +19,17 @@ const manifest = fixture => path.join(fixture.profile.dir, 'package.json') const rows = fixture => boot.composeEntries([boot.readProfilePatches('dsh', profileContext(location(fixture)))]) const backups = fixture => fs.readdirSync(fixture.profile.dir).filter(name => name.startsWith('.dsh-ops-settings-backup-')) const graph = ctx => ctx.get('clientModules').graph().entries.map(row => row.id) +// The saved rows use ids (not names) so the carrier rename of the standard row +// cannot orphan them; comments and an unresolved expression must survive byte for byte. const source = `# Preserve operator comments and expression semantics. -- id: dsh-ops-file - name: dsh-ops/file +- id: dsh-ops-mode-standard disabled: false config: extraGuidance: keep operator text configUpdateVersion: 2 -- id: dsh-ops-shell - name: dsh-ops/shell +- id: dsh-ops-mode-ptc disabled: !!js process.platform !== 'win32' - id: dsh-ops-panel - name: dsh-ops/panel disabled: true config: capture: false @@ -52,33 +52,39 @@ await test('default subpath preserves profiles; optional carrier survives reload assert.deepEqual(graph(runtime.ctx), [], 'legacy subpath cannot advertise browser pages') await shutdown(runtime.ctx) const preview = await carrierPreview(host, location(fixture)) - assert.equal(preview.changedQualifiers, 1) + // Only the bundle name changes; the profile patch text is not rewritten. + assert.deepEqual(preview.files.filter(file => file.path === fixture.profile.patchPath).map(file => [file.before, file.after]), [[source, source]]) assert.equal(fs.readFileSync(manifest(fixture), 'utf8'), originalManifest) assert.equal(fs.readFileSync(fixture.profile.patchPath, 'utf8'), source) assert.deepEqual(backups(fixture), []) // A stop between these two writes remains a valid legacy profile. - fs.writeFileSync(fixture.profile.patchPath, preview.files[0].after) + fs.writeFileSync(fixture.profile.patchPath, preview.files.find(file => file.path === fixture.profile.patchPath).after) assert.deepEqual(rows(fixture), before) fs.writeFileSync(fixture.profile.patchPath, source) const backup = await applyCarrier(host, preview) assert.equal(fs.readFileSync(path.join(backup, 'package.json'), 'utf8'), originalManifest) assert.equal(fs.readFileSync(path.join(backup, 'cordis.patch.yml'), 'utf8'), source) - assert.match(fs.readFileSync(fixture.profile.patchPath, 'utf8'), /# Preserve operator comments/) - assert.match(fs.readFileSync(fixture.profile.patchPath, 'utf8'), /!!js/) + assert.equal(fs.readFileSync(fixture.profile.patchPath, 'utf8'), source) assert.deepEqual(rows(fixture), preview.afterRows) const resolution = await boot.createRuntimeResolution({ installAnchor: fixture.profile.installAnchor, home: fixture.profile.home, profile: boot.loadProfileDirectory('dsh', fixture.profile.dir, fixture.profile.installAnchor) }) assert.ok(resolution.localPackageNames.includes('dsh-ops'), 'the real runtime resolver accepts the nested bundle') - assert.equal(rows(fixture).length, 8, 'seven OPS rows plus unchanged foreign row') + assert.equal(rows(fixture).length, COMPONENTS.length + 1, 'eight OPS rows plus unchanged foreign row') + // The only composition change is the carrier row's name: the standard mode + // row now points at the package root, whose entry applies exactly that mode. + const standardBefore = before.find(row => row.id === 'dsh-ops-mode-standard') + const standardAfter = preview.afterRows.find(row => row.id === 'dsh-ops-mode-standard') + assert.equal(standardAfter.name, 'dsh-ops') + assert.deepEqual({ ...standardAfter, name: standardBefore.name }, standardBefore) assert.equal(JSON.parse(fs.readFileSync(manifest(fixture), 'utf8')).description, metadata.description) assert.deepEqual(JSON.parse(fs.readFileSync(manifest(fixture), 'utf8')).dsh.profile.bundles, [CLIENT_BUNDLE]) for (let restart = 0; restart < 2; restart++) { runtime = await fixture.boot() await runtime.ctx.plugin(ClientModuleRegistry) assert.deepEqual(graph(runtime.ctx), ['dsh-ops']) - assert.equal(runtime.entries().length, 7) - assert.equal(runtime.entries().find(entry => entry.options.id === 'dsh-ops-file').options.config.extraGuidance, 'keep operator text') - const entry = runtime.entries().find(entry => entry.options.id === 'dsh-ops-file') + assert.equal(runtime.entries().length, COMPONENTS.length) + const entry = runtime.entries().find(entry => entry.options.id === 'dsh-ops-mode-standard') + assert.equal(entry.options.config.extraGuidance, 'keep operator text') await entry.update({ disabled: true }); await runtime.ctx.loader.await() await waitFor(() => graph(runtime.ctx).length === 0) await entry.update({ disabled: false }); await runtime.ctx.loader.await() @@ -88,15 +94,15 @@ await test('default subpath preserves profiles; optional carrier survives reload await assert.rejects(carrierPreview(host, location(fixture)), /one-time migration/) } finally { await shutdown(runtime.ctx) } }) -await test('an explicitly disabled file carrier remains off until the builtin row switch enables it', async () => { - const fixture = componentProfile('carrier-disabled', bundle, [{ id: 'dsh-ops-file', name: 'dsh-ops/file', disabled: true, config: { configUpdateVersion: 2 } }]) +await test('an explicitly disabled standard-mode carrier remains off until the builtin row switch enables it', async () => { + const fixture = componentProfile('carrier-disabled', bundle, [{ id: 'dsh-ops-mode-standard', disabled: true, config: { configUpdateVersion: 2 } }]) const plan = await carrierPreview(host, location(fixture)) await applyCarrier(host, plan) const runtime = await fixture.boot() try { await runtime.ctx.plugin(ClientModuleRegistry) assert.deepEqual(graph(runtime.ctx), []) - const entry = runtime.entries().find(entry => entry.options.id === 'dsh-ops-file') + const entry = runtime.entries().find(entry => entry.options.id === 'dsh-ops-mode-standard') assert.equal(entry.disabled, true) await entry.update({ disabled: false }); await runtime.ctx.loader.await() await waitFor(() => graph(runtime.ctx).includes('dsh-ops')) @@ -108,16 +114,16 @@ await test('stale preview, duplicate OPS rows and higher-priority named override fs.appendFileSync(fixture.profile.patchPath, '\n# concurrent edit\n') await assert.rejects(applyCarrier(host, plan), /changed after the preview/) assert.deepEqual(backups(fixture), []) - const duplicate = componentProfile('carrier-duplicate', bundle, [{ insert: [{ id: 'dsh-ops-file', name: 'dsh-ops/file' }] }]) - await assert.rejects(carrierPreview(host, location(duplicate)), /seven unique/) + const duplicate = componentProfile('carrier-duplicate', bundle, [{ insert: [{ id: 'dsh-ops-mode-standard', name: 'dsh-ops/mode-standard' }] }]) + await assert.rejects(carrierPreview(host, location(duplicate)), /eight unique|unique OPS component rows/) assert.deepEqual(backups(duplicate), []) const shadow = componentProfile('carrier-home-shadow', bundle) - fs.writeFileSync(path.join(shadow.profile.home, 'cordis.patch.yml'), '- id: dsh-ops-file\n name: dsh-ops/file\n disabled: true\n') + fs.writeFileSync(path.join(shadow.profile.home, 'cordis.patch.yml'), '- id: dsh-ops-mode-standard\n name: dsh-ops/mode-standard\n disabled: true\n') await assert.rejects(carrierPreview(host, location(shadow)), /External name-qualified/) assert.deepEqual(backups(shadow), []) // Matching current defaults is still a meaningful preference for later upgrades. - const inheritedConfig = yaml.parse(bundle)[0].insert.find(row => row.id === 'dsh-ops-file').config - fs.writeFileSync(path.join(shadow.profile.home, 'cordis.patch.yml'), JSON.stringify([{ id: 'dsh-ops-file', name: 'dsh-ops/file', config: inheritedConfig }])) + const inheritedConfig = yaml.parse(bundle)[0].insert.find(row => row.id === 'dsh-ops-mode-standard').config + fs.writeFileSync(path.join(shadow.profile.home, 'cordis.patch.yml'), JSON.stringify([{ id: 'dsh-ops-mode-standard', name: 'dsh-ops/mode-standard', config: inheritedConfig }])) await assert.rejects(carrierPreview(host, location(shadow)), /External name-qualified/) assert.deepEqual(backups(shadow), []) }) @@ -133,7 +139,6 @@ await test('missing optional patches remain absent and host-invalid empty patche continue } const plan = await carrierPreview(host, location(fixture)) - assert.equal(plan.changedQualifiers, 0) await applyCarrier(host, plan) if (content === undefined) assert.equal(fs.existsSync(fixture.profile.patchPath), false) else assert.equal(fs.readFileSync(fixture.profile.patchPath, 'utf8'), content) @@ -146,11 +151,13 @@ await test('atomic failure restores originals and retains a bounded recovery bac fs.writeFileSync(fixture.profile.patchPath, source) const plan = await carrierPreview(host, location(fixture)) let calls = 0 + // The carrier leaves an unchanged patch alone, so the manifest is the only + // write here; fail that one after optionally committing it. const faulty = { ...host, atomic: { ...atomic, async writeFileAtomic(file, text, options) { calls++ - if (calls === 2) { + if (calls === 1) { if (committedBeforeFailure) await atomic.writeFileAtomic(file, text, options) - throw new Error('injected second-write failure') + throw new Error('injected carrier-write failure') } return atomic.writeFileAtomic(file, text, options) } } } @@ -178,6 +185,6 @@ await test('CLI preview requires an explicit stopped-profile assertion before ap const applied = run('--yes', '--offline') assert.equal(applied.status, 0, applied.stderr); assert.match(applied.stdout, /Saved and verified/) assert.equal(backups(fixture).length, 1) - assert.equal(rows(fixture).find(row => row.id === 'dsh-ops-file').name, 'dsh-ops') + assert.equal(rows(fixture).find(row => row.id === 'dsh-ops-mode-standard').name, 'dsh-ops') }) report('settings-carrier') diff --git a/test/settings-client.test.mjs b/test/settings-client.test.mjs index 9b4e9058..2e97c374 100644 --- a/test/settings-client.test.mjs +++ b/test/settings-client.test.mjs @@ -24,7 +24,7 @@ function capture(file) { } const external = name => name === '@deepseek-ai/cordis' ? Cordis : name === '@deepseek-ai/dsh-client-ui-slots' ? slots : require(name) -await test('shipped browser entry stays lazy and binds exactly three settings pages to the real host registry', async () => { +await test('shipped browser entry stays lazy and binds five settings pages to the real host registry', async () => { const definition = capture(path.join(PACKAGE_ROOT, 'lib/client.js')), imports = [] assert.equal(definition.id, 'dsh-ops'); assert.equal(typeof definition.factory, 'function') assert.deepEqual(imports, []) @@ -71,7 +71,10 @@ await test('shipped browser entry stays lazy and binds exactly three settings pa assert.equal(result.ok, false); assert.match(result.error.message, /abort/i) assert.equal(dictionaries.namespace, 'dshOps') assert.deepEqual(Object.keys(dictionaries.values.en).sort(), Object.keys(dictionaries.values.zh).sort()) - assert.deepEqual(ctx.get('slots').entries('plugins.row.config').map(value => value.options.key).sort(), ['dsh-ops#dsh-ops-discovery', 'dsh-ops#dsh-ops-hide-official', 'dsh-ops#dsh-ops-shell']) + assert.deepEqual(ctx.get('slots').entries('plugins.row.config').map(value => value.options.key).sort(), [ + 'dsh-ops#dsh-ops-discovery', 'dsh-ops#dsh-ops-mode-cordis', 'dsh-ops#dsh-ops-mode-minimal', + 'dsh-ops#dsh-ops-mode-ptc', 'dsh-ops#dsh-ops-mode-standard', + ]) assert.equal(document.querySelectorAll('style[data-dsh-ops-settings]').length, 1) await fiber.dispose() assert.equal(ctx.get('slots').entries('plugins.row.config').length, 0) @@ -94,13 +97,18 @@ await test('browser bundler rejects Node externals and dynamic imports before wr assert.equal(loaded.value, 42) })) -await test('browser package declares an optional client facet while the default bundle preserves the legacy file row', () => { +await test('browser package declares an optional client facet while the default bundle preserves the standard-mode carrier row', () => { const pkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'))) assert.equal(pkg.exports['./client'].default, './lib/client.js') assert.equal(pkg.dsh.client.platform, 'web') assert.ok(pkg.dsh.client.inject.includes('@deepseek-ai/dsh-client-ui-plugin-manager')) + // The carrier renames the standard-mode row; the bundle keeps its explicit specifier. + assert.match(fs.readFileSync(path.join(PACKAGE_ROOT, 'cordis.patch.yml'), 'utf8'), /id: dsh-ops-mode-standard\s+name: dsh-ops\/mode-standard\s/) + for (const component of ['mode-minimal', 'mode-standard', 'mode-ptc', 'mode-cordis', 'compat']) { + assert.equal(pkg.exports[`./${component}`].default, `./lib/components/${component}/index.js`, component) + } + // A saved legacy row stays loadable even though the bundle no longer inserts it. assert.equal(pkg.exports['./file'].default, './lib/components/file/index.js') - assert.match(fs.readFileSync(path.join(PACKAGE_ROOT, 'cordis.patch.yml'), 'utf8'), /id: dsh-ops-file\s+name: dsh-ops\/file\s/) }) dom.window.close() report('settings-client') diff --git a/test/shells-patch.test.mjs b/test/shells-patch.test.mjs index 3db0fbfd..597e6f2c 100644 --- a/test/shells-patch.test.mjs +++ b/test/shells-patch.test.mjs @@ -4,6 +4,8 @@ import path from 'node:path' import { spawnSync } from 'node:child_process' import { parseDocument } from 'yaml' import { resolveConfig } from '../lib/config.js' +import { MODE_CONFIG_KEYS, resolveModeConfig } from '../lib/modes/config.js' +import { COMPONENTS, componentEnabled, componentEntry } from '../lib/components/defaults.js' import { BASH_TOOL_NAME, ENV_OVERRIDES, shellEnvOverrides, resolveShells } from '../lib/shells.js' import { assert, PACKAGE_ROOT, TEST_HOME, report, test, withTempDir } from './lib/harness.mjs' import { config, makeExecutable } from './lib/shells-fixtures.mjs' @@ -22,29 +24,38 @@ await test('the manifest declares the plugin\'s own shell tools as their own nam source: "the plugin's bash layer via host subprocess", publicPrefix: 'ops_', tools: ['bash'], - shellPermission: 'shell component enabled AND publishBashTool AND authoritative per-session DSH policy; restricted modes require host confinement', + shellPermission: 'Mode row enabled with the matching preset AND publishBashTool AND authoritative per-session DSH policy; restricted modes require host confinement', }) // The declared name is the name the tool actually registers under. assert.equal(BASH_TOOL_NAME, `${namespaces[1].publicPrefix}${namespaces[1].tools[0]}`) }) -await test('the bundle patch declares independent components, with no persistent pwsh override', () => { +await test('the bundle patch declares the eight components, with no persistent pwsh override', () => { const entries = patchEntries() assert.equal(entries.length, 1) - assert.deepEqual(entries[0].insert.map(row => row.id), ['dsh-ops-shell', 'dsh-ops-file', 'dsh-ops-ptc', 'dsh-ops-discovery', 'dsh-ops-panel', 'dsh-ops-tui', 'dsh-ops-hide-official']) + assert.deepEqual(entries[0].insert.map(row => row.id), COMPONENTS.map(component => `dsh-ops-${component}`)) + assert.deepEqual(entries[0].insert.map(row => row.name), COMPONENTS.map(componentEntry)) + for (const component of COMPONENTS) { + const row = entries[0].insert.find(item => item.id === `dsh-ops-${component}`) + assert.equal(row.disabled ?? false, !componentEnabled(component, process.platform), `${component} enablement default`) + } const row = id => entries[0].insert.find(item => item.id === `dsh-ops-${id}`) - assert.equal(row('shell').disabled, undefined, 'the shell row is on by default on every platform') - for (const name of ['file', 'ptc', 'discovery']) assert.equal(row(name).disabled, undefined) - for (const name of ['panel', 'tui', 'hide-official']) assert.equal(row(name).disabled, true) + for (const name of ['mode-minimal', 'mode-standard', 'mode-ptc', 'mode-cordis', 'discovery']) assert.equal(row(name).disabled, undefined, `${name} starts enabled`) + for (const name of ['compat', 'panel', 'tui']) assert.equal(row(name).disabled, true, `${name} stays opt-in`) assert.equal(row('panel').config.capture, true, 'a fresh enabled row starts capture') }) -await test('the shipped dsh-ops row states the documented shell defaults', () => { - const row = patchEntries()[0].insert.find((candidate) => candidate.id === 'dsh-ops-shell') - const resolved = resolveConfig(row.config) - assert.equal(resolved.bashPath, undefined) - assert.equal(resolved.publishBashTool, true) - assert.equal(resolved.allowSystemShellFallback, false) +await test('every shipped mode row states the documented shell defaults through its own validator', () => { + for (const mode of ['mode-minimal', 'mode-standard', 'mode-ptc', 'mode-cordis']) { + const row = patchEntries()[0].insert.find((candidate) => candidate.id === `dsh-ops-${mode}`) + // The row's own config is the input; unknown keys must fail loud, so every + // written key has to come from the mode converter's accepted list. + for (const key of Object.keys(row.config ?? {})) assert.ok(MODE_CONFIG_KEYS.includes(key), `${mode}.config.${key} is accepted`) + const resolved = resolveModeConfig(row.config) + assert.equal(resolved.bashPath, undefined, mode) + assert.equal(resolved.publishBashTool, true, mode) + assert.equal(resolved.allowSystemShellFallback, false, mode) + } }) await test('system discovery is opt-in on Windows and is the rung on a POSIX host', async () => {