Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/llm-streaming.md # docs/core-data-structures/llm-streaming.zh.md # docs/event-producer-consumer.md # docs/module-graph.md # docs/subsystems/attachment.i18n.yaml # docs/subsystems/attachment.md # docs/subsystems/attachment.zh.md # docs/subsystems/core.i18n.yaml # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/README.i18n.yaml # packages/README.md # packages/README.zh.md # packages/client/runtime/package.json # packages/client/ui-conversation/package.json # packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/compact/compact-basic/README.i18n.yaml # packages/compact/compact-basic/README.md # packages/compact/compact-basic/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/index.ts # packages/host/apiproxy/src/api/sessions.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/self-modification/tool-cordis/src/api-catalog.ts # pnpm-lock.yaml # scripts/type-equiv.manifest.json
This commit is contained in:
@@ -7,7 +7,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md
|
||||
- **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external/nondeterministic boundaries and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md).
|
||||
- **Initiator-owned private chains derive, then capture.** Under `ctx.agents.withInitiator()`, recover the Agent at each orchestration entry, derive `agent.session`, and let operation-local helpers close over it. Keep `Agent` and `Session` explicit at lifecycle, session-log, service, authority, worker/process, persistence, and wire interfaces; do not widen a leaf helper from `Session` to `Context` merely to hide a parameter ([rationale](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)).
|
||||
- **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement boundary; otherwise fold it while preserving rollback, callback containment, and quiescence.
|
||||
- **Shape capability interfaces around all current consumers.** Keep tool-schema, Loader, UI, transport, and backend-specific behavior in the consumer or adapter; do not let one consumer dictate the interface ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). Inverse smell: a public service method with one internal caller — pass a private capability closure instead (`RunCodeBridgeOptions`).
|
||||
- **Shape Service Definitions around all current Consumers.** Keep tool-schema, Loader, UI, transport, and provider-specific behavior in the Consumer or provider; do not let one Consumer dictate the service contract ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). Inverse smell: a public service method with one internal caller — pass a private capability closure instead (`RunCodeBridgeOptions`).
|
||||
- **Require a current owner and need.** Tie each abstraction, state machine, option, defensive copy, and compatibility path to a current contract or production consumer, and keep behavior in its owning plugin or service.
|
||||
- **Require evidence for public choices.** Configurability does not justify an unsupported default, public operation set, format, or imported external concept. Use current-consumer evidence or relevant prior art; otherwise require an explicit value or defer the choice.
|
||||
- **Write model-facing contracts from the model's perspective.** Prompts, tool schemas, results, and diagnostics contain only task-relevant concepts, not UI, transport, or implementation vocabulary. Pin stable model-visible text verbatim and dynamic behavior through snapshots or end-to-end coverage.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/README.md
|
||||
README.md: 53c580980198d5f33096d6b120db54026b68f248
|
||||
README.zh.md: 670acb04035ef30a4e837c3be1a58e1a67ce9af6
|
||||
README.md: df071b80abff37e0892f0bec444869e33af78de6
|
||||
README.zh.md: f3fa2946218c4fe541c115c5cdfb5bf2e6cd4985
|
||||
|
||||
@@ -6,7 +6,7 @@ Packages use the `@deepseek-ai/dsh-*` scope. Cordis `Service` subclasses and fun
|
||||
|
||||
## Hierarchy
|
||||
|
||||
`packages/<group>/<pkg>/` contains `@deepseek-ai/dsh-<pkg>` packages. **Each group README is the canonical package/ctx-key map.**
|
||||
Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Group READMEs own package/ctx-key maps.**
|
||||
|
||||
| Group | Role | Release expectation |
|
||||
|---|---|---|
|
||||
@@ -16,41 +16,39 @@ Packages use the `@deepseek-ai/dsh-*` scope. Cordis `Service` subclasses and fun
|
||||
| [`goal/`](goal/README.md) | Same-session goal persistence and lifecycle | Product — stable surface |
|
||||
| [`feedback/`](feedback/README.md) | Human feedback | Product — stable surface |
|
||||
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
|
||||
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-tree implementation | Product — stable surface |
|
||||
| [`e2b/`](e2b/README.md) | E2B providers | POC |
|
||||
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition + local process-tree provider | Product — stable surface |
|
||||
| [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface |
|
||||
| [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local impl, model-facing tools | Product — stable surface |
|
||||
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: runtime seam for model-written programs + worker-thread backend | Product — stable surface |
|
||||
| [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface |
|
||||
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: Service Definition + worker-thread provider + Code Mode Consumer | Product — stable surface |
|
||||
| [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | Product — stable surface |
|
||||
| [`fs/`](fs/README.md) | Filesystem capability family: seam, local impl, model-facing file tools, bash-backed discovery tools | Product — stable surface |
|
||||
| [`lsp/`](lsp/README.md) | LSP capability family: seam, generic stdio provider, and the `lsp` tool | Product — stable surface |
|
||||
| [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable surface |
|
||||
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface |
|
||||
| [`compact/`](compact/README.md) | Compaction capability family: Service Definition + basic provider + command Consumer | Product — stable surface |
|
||||
| [`context/`](context/README.md) | Model-visible request context, including workspace instructions and time context | Product — stable surface |
|
||||
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
|
||||
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry contract and the model-facing delegation tool | Product — stable surface |
|
||||
| [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface |
|
||||
| [`workflow/`](workflow/README.md) | Script seam, worker-thread engine, and model-facing `workflow`/`ralph` tools | Product — stable surface |
|
||||
| [`workflow/`](workflow/README.md) | Workflow seam, worker-thread engine, and model-facing `workflow`/`ralph` tools | Product — stable surface |
|
||||
| [`web/`](web/README.md) | Web capability family: seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
|
||||
| [`attachment/`](attachment/README.md) | Attachments | Product — stable surface |
|
||||
| [`attachment/`](attachment/README.md) | Durable attachment identity, validation, and local content-addressed storage | Product — stable surface |
|
||||
| [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface |
|
||||
| [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable surface |
|
||||
| [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface |
|
||||
| [`timeout/`](timeout/README.md) | Tool-call `tools/execute` deadline enforcement | Product — stable surface |
|
||||
| [`guard/`](guard/README.md) | Loop-hygiene advisory repeat-call reminders | Product — stable surface |
|
||||
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders + the `tools/execute` deadline enforcer | Product — stable surface |
|
||||
| [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable surface |
|
||||
| [`cordis/`](cordis/README.md) | Cordis runtime integration: self-inspection, temporary Plugins, restricted repository Plugin loading | Product — stable surface |
|
||||
| [`self-modification/`](self-modification/README.md) | The agent modifies its own runtime: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) and restricted repository Plugin loading | Product — stable surface |
|
||||
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
|
||||
| [`session-persistence/`](session-persistence/README.md) | Persistence seam + JSONL/SQLite backends | Product — stable surface |
|
||||
| [`session-projection/`](session-projection/README.md) | Projection seam: domain fold units serve whole values | Product — stable surface |
|
||||
| [`session/`](session/README.md) | Durable session data plane: persistence seam + JSONL/SQLite backends, projection seam, log-backed titles, session reporting | Product — stable surface |
|
||||
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |
|
||||
| [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service and opt-in LLM providers | Product — stable surface |
|
||||
| [`settings/`](settings/README.md) | User-settings seam + file-backed provider | Product — stable surface |
|
||||
| [`credentials/`](credentials/README.md) | Credential-reference seam + env-over-`.env` provider | Product — stable surface |
|
||||
| [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface |
|
||||
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface |
|
||||
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface |
|
||||
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
|
||||
| [`scaffold/`](scaffold/README.md) | Create/launch/drive project tooling: helper, launcher, initializer, wire protocol with both ends, launcher telemetry | Product — stable surface |
|
||||
| [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | JSON-RPC integration, approval/interaction seams, ask-user tool | Product — stable surface |
|
||||
| [`interaction/`](interaction/README.md) | Human-collaboration plane: approval/interaction seams, permission preset, commands, ask-user tool | Product — stable surface |
|
||||
| [`boot/`](boot/README.md) | Shared app-bin boot glue | Product — stable surface |
|
||||
| [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | Product — stable surface |
|
||||
| [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | Product — stable surface |
|
||||
| [`experimental/`](experimental/README.md) | Prototypes and internal plugins | Unreleased |
|
||||
@@ -64,6 +62,6 @@ New packages join existing groups; new groups update their README and this table
|
||||
|
||||
The dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI).
|
||||
|
||||
**Extension plugins depend on interfaces, never the concrete loop.** `dsh-agent-loop` is swappable; UI, hook, and tool plugins use `dsh-agent`. Composition bundles, including `dsh-agent-spine-demo`, may depend on spine plugins. Capabilities split into interface / implementation / consumer packages; see [capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md).
|
||||
**Extension plugins depend on Service Definitions, never concrete providers.** `dsh-agent-loop` is swappable; UI, hook, and tool plugins use `dsh-agent`. Composition bundles, including `dsh-agent-spine-demo`, may depend on spine plugins. Capabilities separate Service Definition / Service provider / Consumer roles when they evolve independently; see [capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md).
|
||||
|
||||
Package READMEs cover purpose, APIs, extension points, and [Model Experience](../docs/cookbook/adding-a-package.md#4-write-the-package-readme) unless on the model-agnostic [omission allowlist](../scripts/verify-package-readme-model-experience.ts). They also carry `## Known Limitations and Deferred Work` or use its [allowlist](../scripts/verify-package-readme-limitations.ts).
|
||||
|
||||
@@ -6,53 +6,51 @@
|
||||
|
||||
## 层级结构
|
||||
|
||||
`packages/<group>/<pkg>/` 包含 `@deepseek-ai/dsh-<pkg>` 包。**每个组 README 是规范的包/ctx 键映射。**
|
||||
包按组置于 `packages/<group>/<pkg>/`;包名仍为 `@deepseek-ai/dsh-<pkg>`。**组 README 负责包/ctx 键映射。**
|
||||
|
||||
| 组 | 职责 | 发布预期 |
|
||||
|---|---|---|
|
||||
| [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定表面 |
|
||||
| [`api/`](api/README.md) | Remote BFF 装配与 TypeRT RPC Gateway | 产品:稳定表面 |
|
||||
| [`typert/`](typert/README.md) | 类型图生成、产物加载与运行时注册表 | 产品:稳定表面 |
|
||||
| [`goal/`](goal/README.md) | 同会话 goal 的持久化与生命周期 | 产品:稳定表面 |
|
||||
| [`feedback/`](feedback/README.md) | 人类反馈 | 产品:稳定表面 |
|
||||
| [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定表面 |
|
||||
| [`subprocess/`](subprocess/README.md) | 进程管理能力系列:spawn seam + 本地进程树实现 | 产品:稳定表面 |
|
||||
| [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定表面 |
|
||||
| [`pty/`](pty/README.md) | 持久 PTY 能力系列:按所有者隔离的会话、本地实现和面向模型的工具 | 产品:稳定表面 |
|
||||
| [`code-runtime/`](code-runtime/README.md) | 代码执行能力系列:面向模型所写程序的运行时 seam + worker 线程后端 | 产品:稳定表面 |
|
||||
| [`sandbox/`](sandbox/README.md) | 进程限制 seam;bwrap/Landlock/Seatbelt 后端 | 产品:稳定表面 |
|
||||
| [`fs/`](fs/README.md) | 文件系统能力系列:seam、本地实现、面向模型的文件工具、bash 后端发现工具 | 产品:稳定表面 |
|
||||
| [`lsp/`](lsp/README.md) | LSP 能力系列:seam、通用 stdio 提供方和 `lsp` 工具 | 产品:稳定表面 |
|
||||
| [`skill/`](skill/README.md) | Skill(技能)能力系列:提供方注册表、本地提供方和面向模型的目录/加载器 | 产品:稳定表面 |
|
||||
| [`compact/`](compact/README.md) | 压缩(compaction)能力系列:抽象 seam + 基础后端(工具延后) | 产品:稳定表面 |
|
||||
| [`context/`](context/README.md) | 模型可见请求上下文,包括 workspace 指令和时间上下文 | 产品:稳定表面 |
|
||||
| [`subagent/`](subagent/README.md) | Subagent 能力系列:提供方注册表 seam 和面向模型的委托工具 | 产品:稳定表面 |
|
||||
| [`tasks/`](tasks/README.md) | 通用后台任务运行时和面向模型的 `task_*` 控制工具 | 产品:稳定表面 |
|
||||
| [`workflow/`](workflow/README.md) | 脚本 seam、worker 线程引擎和面向模型的 `workflow`/`ralph` 工具 | 产品:稳定表面 |
|
||||
| [`web/`](web/README.md) | Web 能力系列:seam、搜索/获取提供方实现和面向模型的 Web 工具 | 产品:稳定表面 |
|
||||
| [`attachment/`](attachment/README.md) | 持久附件 | 产品:稳定表面 |
|
||||
| [`spill/`](spill/README.md) | 溢出能力系列:存储 seam、本地实现、工具结果溢出策略 | 产品:稳定表面 |
|
||||
| [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定表面 |
|
||||
| [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定表面 |
|
||||
| [`timeout/`](timeout/README.md) | 工具调用 `tools/execute` 截止时间强制执行 | 产品:稳定表面 |
|
||||
| [`guard/`](guard/README.md) | 循环卫生建议性重复调用提醒 | 产品:稳定表面 |
|
||||
| [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定表面 |
|
||||
| [`cordis/`](cordis/README.md) | Cordis 运行时集成:自检、临时 Plugin、受限 repository Plugin 加载 | 产品:稳定表面 |
|
||||
| [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude Code/Codex 协议格式库 | 产品:稳定表面 |
|
||||
| [`session-persistence/`](session-persistence/README.md) | 持久化 seam + JSONL/SQLite 后端 | 产品:稳定表面 |
|
||||
| [`session-projection/`](session-projection/README.md) | 投影 seam:领域折叠单元供给全量值 | 产品:稳定表面 |
|
||||
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 |
|
||||
| [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务与选用 LLM 提供方 | 产品:稳定表面 |
|
||||
| [`settings/`](settings/README.md) | 用户设置 seam + 文件 provider | 产品:稳定表面 |
|
||||
| [`credentials/`](credentials/README.md) | 凭据引用 seam + 环境叠加 `.env` provider | 产品:稳定表面 |
|
||||
| [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 |
|
||||
| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 |
|
||||
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 |
|
||||
| [`sdk/`](sdk/README.md) | 项目 SDK 工具 | 产品:稳定表面 |
|
||||
| [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定表面 |
|
||||
| [`ui/`](ui/README.md) | JSON-RPC 集成、批准/交互 seam、用户问答工具 | 产品:稳定表面 |
|
||||
| [`host/`](host/README.md) | web GUI 宿主半侧:API 网关 + HTTP 路由服务器 | 产品:稳定表面 |
|
||||
| [`client/`](client/README.md) | web GUI 浏览器半侧:shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定表面 |
|
||||
| [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定接口 |
|
||||
| [`api/`](api/README.md) | Remote BFF 装配与 TypeRT RPC Gateway | 产品:稳定接口 |
|
||||
| [`typert/`](typert/README.md) | 类型图生成、产物加载与运行时注册表 | 产品:稳定接口 |
|
||||
| [`goal/`](goal/README.md) | 同会话 goal 的持久化与生命周期 | 产品:稳定接口 |
|
||||
| [`feedback/`](feedback/README.md) | 人类反馈 | 产品:稳定接口 |
|
||||
| [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定接口 |
|
||||
| [`e2b/`](e2b/README.md) | E2B 提供方 | POC |
|
||||
| [`subprocess/`](subprocess/README.md) | 进程管理能力系列:Service Definition + 本地进程树提供方 | 产品:稳定接口 |
|
||||
| [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定接口 |
|
||||
| [`pty/`](pty/README.md) | 持久 PTY 能力系列:按所有者隔离的会话、本地实现和面向模型的工具 | 产品:稳定接口 |
|
||||
| [`code-runtime/`](code-runtime/README.md) | 代码执行能力系列:Service Definition + worker 线程提供方 + Code Mode Consumer | 产品:稳定接口 |
|
||||
| [`sandbox/`](sandbox/README.md) | 进程限制 seam;bwrap/Landlock/Seatbelt 后端 | 产品:稳定接口 |
|
||||
| [`fs/`](fs/README.md) | 文件系统能力系列:seam、本地实现、面向模型的文件工具、bash 后端发现工具 | 产品:稳定接口 |
|
||||
| [`lsp/`](lsp/README.md) | LSP 能力系列:seam、通用 stdio 提供方和 `lsp` 工具 | 产品:稳定接口 |
|
||||
| [`skill/`](skill/README.md) | skill(技能)能力系列:提供方注册表、本地提供方和面向模型的目录/加载器 | 产品:稳定接口 |
|
||||
| [`compact/`](compact/README.md) | 压缩(compaction)能力系列:Service Definition + 基础提供方 + 命令 Consumer | 产品:稳定接口 |
|
||||
| [`context/`](context/README.md) | 模型可见请求上下文,包括 workspace 指令和时间上下文 | 产品:稳定接口 |
|
||||
| [`subagent/`](subagent/README.md) | subagent 能力系列:提供方注册表约定和面向模型的委托工具 | 产品:稳定接口 |
|
||||
| [`tasks/`](tasks/README.md) | 通用后台任务运行时和面向模型的 `task_*` 控制工具 | 产品:稳定接口 |
|
||||
| [`workflow/`](workflow/README.md) | 工作流 seam、worker 线程引擎和面向模型的 `workflow`/`ralph` 工具 | 产品:稳定接口 |
|
||||
| [`web/`](web/README.md) | Web 能力系列:seam、搜索/获取提供方实现和面向模型的 Web 工具 | 产品:稳定接口 |
|
||||
| [`attachment/`](attachment/README.md) | 持久附件标识、校验与本地内容寻址存储 | 产品:稳定接口 |
|
||||
| [`spill/`](spill/README.md) | spill 能力系列:存储 seam、本地实现、工具结果 spill 策略 | 产品:稳定接口 |
|
||||
| [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定接口 |
|
||||
| [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定接口 |
|
||||
| [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 + `tools/execute` 截止时间强制执行器 | 产品:稳定接口 |
|
||||
| [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定接口 |
|
||||
| [`self-modification/`](self-modification/README.md) | agent 修改自身运行时:检查实时运行时的插件与服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)),以及受限仓库插件加载 | 产品:稳定接口 |
|
||||
| [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude Code/Codex 协议格式库 | 产品:稳定接口 |
|
||||
| [`session/`](session/README.md) | 持久会话数据平面:持久化 seam + JSONL/SQLite 后端、投影 seam、日志支持的标题、会话上报 | 产品:稳定接口 |
|
||||
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定接口 |
|
||||
| [`settings/`](settings/README.md) | 用户设置 seam + 文件提供方 | 产品:稳定接口 |
|
||||
| [`credentials/`](credentials/README.md) | 凭据引用 seam + 环境叠加 `.env` 提供方 | 产品:稳定接口 |
|
||||
| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定接口 |
|
||||
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定接口 |
|
||||
| [`scaffold/`](scaffold/README.md) | 创建/启动/驱动项目的工具:helper、启动器、初始化器、带两端的通信协议、启动器 telemetry | 产品:稳定接口 |
|
||||
| [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定接口 |
|
||||
| [`interaction/`](interaction/README.md) | 人机协作平面:批准/交互 seam、权限预设、命令、用户问答工具 | 产品:稳定接口 |
|
||||
| [`boot/`](boot/README.md) | 共享的 app bin 启动粘合层 | 产品:稳定接口 |
|
||||
| [`host/`](host/README.md) | web GUI 宿主半侧:API 网关 + HTTP 路由服务器 | 产品:稳定接口 |
|
||||
| [`client/`](client/README.md) | web GUI 浏览器半侧:shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定接口 |
|
||||
| [`experimental/`](experimental/README.md) | 原型和内部插件 | 未发布 |
|
||||
| [`examples/`](examples/README.md) | 演示组合包(agent-spine + CLI/ACP/JSON-RPC bin),由叶节点加载 | 支持:示例基础设施 |
|
||||
| [`support/`](support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 |
|
||||
@@ -64,6 +62,6 @@
|
||||
|
||||
依赖图由工具生成:[docs/module-graph.md](../docs/module-graph.md)(`pnpm run gen-module-graph`,CI 中有新鲜度门禁)。
|
||||
|
||||
**扩展插件依赖接口,绝不依赖具体循环。** `dsh-agent-loop` 可替换;UI、钩子和工具插件使用 `dsh-agent`。包括 `dsh-agent-spine-demo` 在内的组合包可以依赖主干插件。能力拆分为接口/实现/消费方包;详见[能力 seam](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)。
|
||||
**扩展插件依赖 Service Definition,绝不依赖具体提供方。** `dsh-agent-loop` 可替换;UI、钩子和工具插件使用 `dsh-agent`。包括 `dsh-agent-spine-demo` 在内的组合包可以依赖主干插件。能力在 Service Definition/Service provider/Consumer 角色需要独立演进时将其分离;详见[能力 seam](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)。
|
||||
|
||||
包 README 覆盖用途、API、扩展点和[模型体验](../docs/cookbook/adding-a-package.md#4-write-the-package-readme);列入模型无关[省略允许清单](../scripts/verify-package-readme-model-experience.ts)的包除外。它们还要包含 `## Known Limitations and Deferred Work`,或使用其[允许清单](../scripts/verify-package-readme-limitations.ts)。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/acp/README.md
|
||||
README.md: 3ba247598f29f2244456061fe7f9a3282086148f
|
||||
README.zh.md: c13bc05b12fff2ef97b4d547936aa14a6556430a
|
||||
README.md: 97af6d164b265bf0e98e3c9f5a444cffad4face5
|
||||
README.zh.md: fb9f419e23aef5b3bdf0bdd486b331eb2f3f236a
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The ACP group exposes harness agents to programmatic clients. It is an interoperability transport, not a presentation or human-interaction layer.
|
||||
The ACP group exposes harness agents to programmatic clients over the Agent Client Protocol. It is an interoperability transport, not a presentation or human-interaction layer; the matching out-of-process subagent *client* lives in [`subagent/subagent-acp`](../subagent/subagent-acp/README.md) because it implements the subagent provider interface.
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| [`acp/`](acp/README.md) | Automation-only ACP server. |
|
||||
|
||||
The matching out-of-process subagent client remains in [`subagent/subagent-acp`](../subagent/subagent-acp/README.md) because it implements the subagent provider interface; arbitrary ACP clients may drive the same server contract.
|
||||
The server contract is documented in [`acp/README.md`](acp/README.md).
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
ACP(Agent Client Protocol)组将 harness 中的 agent(智能体)公开给程序化客户端。它是互操作传输层,而非展示层或人机交互层。
|
||||
ACP(Agent Client Protocol)组通过该协议将 harness 中的 agent(智能体)公开给程序化客户端。它是互操作传输层,不是展示或人机交互层;配对的进程外 subagent *客户端*在 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现的是 subagent 提供方接口。
|
||||
|
||||
| 包 | 职责 |
|
||||
|---|---|
|
||||
| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器。 |
|
||||
|
||||
与之匹配的进程外 subagent 客户端仍位于 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现 subagent 提供方接口;任意 ACP 客户端都可以按照同一服务器契约驱动该服务器。
|
||||
服务器约定见 [`acp/README.md`](acp/README.md)。
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/acp/acp/README.md
|
||||
README.md: 9cc4a5e271c7200f6ad8799a4b8fa9e64b2ca893
|
||||
README.zh.md: 82aa5df2c7d87312d4b619a09582cc0c2d884398
|
||||
README.zh.md: eafae5602bdeb408ef548a9e706e059bd99bde17
|
||||
|
||||
@@ -17,14 +17,14 @@
|
||||
|
||||
两个字段都是可选的,以便由另一个 agent/request 监听器提供目标。可运行的 ACP 组合同时要求两者。
|
||||
|
||||
## 协议契约
|
||||
## 协议约定
|
||||
|
||||
| 方法 | 行为 |
|
||||
|---|---|
|
||||
| `initialize` | 协商受支持的版本,并仅公布基线提示词(无图像、音频或嵌入上下文能力)。不公布会话、编辑器、终端、文件系统或 MCP 能力。 |
|
||||
| `authenticate` | 空操作,因为服务器不公布身份验证方法。 |
|
||||
| `session/new` | 以绝对路径作为主 `cwd` 创建新 agent;接受空的 `additionalDirectories` 和 `mcpServers`,拒绝非空值。 |
|
||||
| `session/prompt` | 拼接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并等待整个 agent 进入 idle。正常完全停稳时报告 `end_turn`;显式 ACP 取消、资源释放,或准入被丢弃的提示词(turnless 槽位)时报告 `cancelled`。 |
|
||||
| `session/prompt` | 拼接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并等待整个 agent 进入空闲状态。正常完全停稳时报告 `end_turn`;显式 ACP 取消、资源释放,或准入被丢弃的提示词(无轮次槽位)时报告 `cancelled`。 |
|
||||
| `session/cancel` | 仅取消指定的 agent,并将其待处理提示词结算为 `cancelled`;未知 id 为空操作。 |
|
||||
| `session/update` | 为每个非空文本块发出一个 `agent_message_chunk`;这些文本块来自已提交的 `assistant/message`。省略原始增量和非消息事件。 |
|
||||
| `session/request_permission` | 为携带工具调用 id、由桥接层拥有的批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 |
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle,并等待全部结果结算后才报告失败。其他共享该上下文的前端会保留其可继续森林和准入。因此,仅 ACP 的插件重载不会遗留 agent。
|
||||
|
||||
ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。已提交的 assistant 消息会在整个自有活动期间流式输出,agent 进入 idle 前发生的 steering(中途引导)或注入工作也可能参与其中。因此,因 token 上限而结束的轮次不会成为提示词级 ACP 停止原因(它们以 `end_turn` 结算);关联轮次上的模型错误会立即 reject 提示词。
|
||||
ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。已提交的 assistant 消息会在整个自有活动期间流式输出,agent 进入空闲状态前发生的 steering(中途引导)或注入工作也可能参与其中。因此,因 token 上限而结束的轮次不会成为提示词级 ACP 停止原因(它们以 `end_turn` 结算);关联轮次上的模型错误会立即拒绝该提示词。
|
||||
|
||||
## 运行
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ function internalError(detail: string): RequestError {
|
||||
return RequestError.internalError(undefined, detail)
|
||||
}
|
||||
|
||||
/** Plugin config: the provider/model target used for each ACP-created agent. */
|
||||
/** Plugin config: the provider/model selection used for each ACP-created agent. */
|
||||
export interface AcpConfig {
|
||||
/** Provider route for created agents. */
|
||||
provider?: string
|
||||
@@ -100,7 +100,7 @@ interface SessionRecord {
|
||||
/**
|
||||
* Mount the automation-only ACP server.
|
||||
* @param ctx - Cordis context carrying the agent factory and session events.
|
||||
* @param config - Initial provider/model target and optional test transport.
|
||||
* @param config - Initial provider/model selection and optional test transport.
|
||||
*/
|
||||
export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// ACP handlers execute outside this plugin's injection scope, so capture the
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
"path": "../../interaction/user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/api/README.md
|
||||
README.md: 7c75e8012459266e0ce09c97416d140e5ac777e1
|
||||
README.zh.md: 87bd15fc4e5ad23ef785f7c9ee805a4aa1a35e46
|
||||
README.zh.md: f4e882570d2dc929cca466e0bdb05bb2943f2c93
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
| [`remotes/`](remotes/README.md) | Host Agent/Session lookup 策略与 Client Remote contribution 装配 | 无服务;配置 `ctx.typert` 并消费 `ctx.remote` |
|
||||
| [`gateway/`](gateway/README.md) | Host TypeRT 分发器与 Client Remote endpoint | `ctx.typertGateway` / `ctx.remote` |
|
||||
|
||||
运行时依赖方向为 `remotes → gateway → connection → webserver`:BFF 消费共享的 `TypeRTClientRemote` 契约,Gateway 把传输交给 Connection,Connection 再挂载到 HTTP server。Cordis 服务注入与 Client 模块元数据在不让 Remotes Client 入口导入具体 Gateway 实现的前提下维持该顺序。
|
||||
运行时依赖方向为 `remotes → gateway → connection → webserver`:BFF 消费共享的 `TypeRTClientRemote` 约定,Gateway 把传输交给 Connection,Connection 再挂载到 HTTP server。Cordis 服务注入与 Client 模块元数据在不让 Remotes Client 入口导入具体 Gateway 实现的前提下维持该顺序。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- Connection 与 WebServer 仍位于 [`client/connection`](../client/connection/README.md) 和 [`host/webserver`](../host/webserver/README.md);后续可以只移动包,将它们放到 `api/connection` 和 `api/webserver` 下,而无需改变服务契约。
|
||||
- Connection 与 WebServer 仍位于 [`client/connection`](../client/connection/README.md) 和 [`host/webserver`](../host/webserver/README.md);后续可以只移动包,将它们放到 `api/connection` 和 `api/webserver` 下,而无需改变服务约定。
|
||||
- 旧 API Proxy 仍位于 [`host/apiproxy`](../host/apiproxy/README.md),作为尚未迁移到 Remote 的方法的回退路径。它使用由 `api-remotes` 持有的 Host resolver,使已迁移与旧方法共用同一套 Agent/Session 身份策略。
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/api/gateway/README.md
|
||||
README.md: 0e1a03d2016b8cfbe165dbf1b0a9802290b29502
|
||||
README.zh.md: 6b5ccff2340405cc0045147239c5bd4f3eead7da
|
||||
README.zh.md: f8c01b489f51fb5e78b608dd9c24a36c7bc64c3a
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
为 Host 与 Client 两侧的 Cordis 环境提供 TypeRT RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 契约,并将业务选择交给 API Remotes,将传输、请求关联、信任和响应封装交给 Connection。
|
||||
为 Host 与 Client 两侧的 Cordis 环境提供 TypeRT RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 约定,并将业务选择交给 API Remotes,将传输、请求关联、信任和响应封装交给 Connection。
|
||||
|
||||
## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`)
|
||||
|
||||
@@ -20,7 +20,7 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle
|
||||
|
||||
每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。
|
||||
|
||||
生成的声明合并通过共享的 `TypeRTClientRemote` 契约提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。
|
||||
生成的声明合并通过共享的 `TypeRTClientRemote` 约定提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/api/remotes/README.md
|
||||
README.md: 3d9de0955faefe37c95ff8bb792d57c4fa1f1a3a
|
||||
README.zh.md: 7490d68781d3a7b0002b73fe06056ec86c144575
|
||||
README.zh.md: 12add6f8efc5b6af3e9b74b26a1abb2bb3936e0a
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway。
|
||||
|
||||
本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 契约,均可复用其 Client face。
|
||||
本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 约定,均可复用其 Client face。
|
||||
|
||||
## 构建边界
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
"path": "../../session/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/README.md
|
||||
README.md: 601782caad24d3555a206365a3f1954d81af1cf0
|
||||
README.zh.md: 8b96c4f80ba8776bfd8bdde178178cea946ff36c
|
||||
README.md: edffdd67a982a885b7e3e537be77c8971f5b697b
|
||||
README.zh.md: 8cb56f38df4999cb2055b9e2808ce092bc7a8282
|
||||
|
||||
@@ -6,7 +6,7 @@ The capability family spans the canonical executor seam, its implementations, th
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`bash/`](bash/README.md) | Defines the executor contract shared by implementations and consumers. | `ctx.bash` |
|
||||
| [`bash/`](bash/README.md) | Defines the executor contract shared by Service providers and Consumers. | `ctx.bash` |
|
||||
| [`bash-local/`](bash-local/README.md) | Executes commands through the local [`subprocess`](../subprocess/README.md) service. | (registers `ctx.bash`) |
|
||||
| [`bash-sandbox/`](bash-sandbox/README.md) | Applies the configured [`sandbox`](../sandbox/README.md) backend before local execution. | (registers `ctx.bash`) |
|
||||
| [`pwsh-local/`](pwsh-local/README.md) | Executes PowerShell commands with Windows-specific process behavior. | (registers `ctx.bash`) |
|
||||
@@ -15,3 +15,5 @@ The capability family spans the canonical executor seam, its implementations, th
|
||||
| [`tool-pwsh/`](tool-pwsh/README.md) | Exposes PowerShell execution to the model. | (registers on `ctx.tools`) |
|
||||
|
||||
A leaf `cordis.yml` selects one executor implementation and the model-facing tools it needs. A sandboxed composition also selects a `ctx.sandbox` provider; the [ACP example](../../examples/acp-agent/) shows one complete wiring.
|
||||
|
||||
The subsystem reference — request/spec vocabulary, results, background processes, the service, and events — is [docs/subsystems/bash.md](../../docs/subsystems/bash.md).
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
| 包 | 职责 | ctx key |
|
||||
|---|---|---|
|
||||
| [`bash/`](bash/README.md) | 定义实现与消费方共享的执行器契约。 | `ctx.bash` |
|
||||
| [`bash/`](bash/README.md) | 定义 Service provider 与 Consumer 共享的执行器约定。 | `ctx.bash` |
|
||||
| [`bash-local/`](bash-local/README.md) | 通过本地 [`subprocess`](../subprocess/README.md) 服务执行命令。 | (注册 `ctx.bash`) |
|
||||
| [`bash-sandbox/`](bash-sandbox/README.md) | 在本地执行前应用已配置的 [`sandbox`](../sandbox/README.md) 后端。 | (注册 `ctx.bash`) |
|
||||
| [`pwsh-local/`](pwsh-local/README.md) | 以 Windows 专用进程行为执行 PowerShell 命令。 | (注册 `ctx.bash`) |
|
||||
@@ -15,3 +15,5 @@
|
||||
| [`tool-pwsh/`](tool-pwsh/README.md) | 向模型公开 PowerShell 执行。 | (注册到 `ctx.tools`) |
|
||||
|
||||
叶节点 `cordis.yml` 选择一个执行器实现和所需的面向模型工具。沙箱化组合还会选择一个 `ctx.sandbox` 提供方;[ACP(Agent Client Protocol)示例](../../examples/acp-agent/)展示一套完整接线。
|
||||
|
||||
子系统参考——请求/spec 词汇、结果、后台进程、服务与事件——见 [docs/subsystems/bash.md](../../docs/subsystems/bash.md)。
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/bash-env/README.md
|
||||
README.md: 7b939326d4effd14fc83ef0ad4e133f019f1011f
|
||||
README.zh.md: b6f3aca41771f1ca990a4b708cd53119b8e8ad78
|
||||
README.zh.md: 4d80d9d34f2be18e07d57d2427eb841f61f1ccfc
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
工具无关的 shell 环境插件:拥有 `ctx.bashEnv` 注册表,管理受信任的、每次执行收集的 `DSH_*` 变量,供模型可见的 shell 工具(`dsh-tool-bash`、`dsh-tool-pwsh`)收集进每次 shell 调用的环境。内置 shell 事实(`DSH_HOME`、`DSH_SHELL=1`、`DSH_SESSION_ID`)归注册表自身所有;其他插件可以注册额外的可枚举事实,注册随插件纤维(fiber)释放,重复所有权或未声明的运行时键会响亮失败。
|
||||
|
||||
包根导出 Cordis 插件契约(`name`、`inject`、`Config`、`apply`)以及 `BashEnvRegistry` 服务类及其 contributor 类型;消费者在加载本插件后使用 `ctx.bashEnv`。
|
||||
包根导出 Cordis 插件约定(`name`、`inject`、`Config`、`apply`)以及 `BashEnvRegistry` 服务类及其 contributor 类型;消费方在加载本插件后使用 `ctx.bashEnv`。
|
||||
|
||||
## Config
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ function execution(sessionId?: string): ToolExecution {
|
||||
signal: testToolSignal,
|
||||
token: Symbol('bash-env-test') as ToolExecution['token'],
|
||||
callId: CallId('bash-env-call'),
|
||||
rootCallId: CallId('bash-env-call'),
|
||||
name: 'bash',
|
||||
arguments: { command: 'true' },
|
||||
...(sessionId === undefined
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
"path": "../../session/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/bash-local/README.md
|
||||
README.md: cb40cb8fa40d95d5b4589b7c804f450a2bf38c8e
|
||||
README.zh.md: bd4f73babdb47ff92e87e20eb7d60657ed515ec4
|
||||
README.md: cb8e7f0ae766d9b1c5f1678e77d35992085d3d52
|
||||
README.zh.md: 20af9c18998c6f3f0403c50f3a8ac599607dc094
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Local implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `LocalBashExecutor` spawns `bash -c <command>` per call as a managed process group through `ctx.subprocess`, and owns everything bash-shaped — command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's.
|
||||
Local Service provider for the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `LocalBashExecutor` spawns `bash -c <command>` per call as a managed process group through `ctx.subprocess`, and owns everything bash-shaped — command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's.
|
||||
|
||||
The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
`@deepseek-ai/dsh-bash` 执行器 seam 的本地实现,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess` 把 `bash -c <command>` 作为受管进程组 spawn,并负责所有 Bash 层职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。以 spill 文件兜底的有界输出、凭据清除、kill 升级和 dispose(资源释放)等进程组机制则由 subprocess 服务负责。
|
||||
`@deepseek-ai/dsh-bash` 执行器 seam 的本地 Service provider,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess` 把 `bash -c <command>` 作为受管进程组 spawn,并负责所有 Bash 层职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。以 spill 文件兜底的有界输出、凭据清除、kill 升级和 dispose(资源释放)等进程组机制则由 subprocess 服务负责。
|
||||
|
||||
包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Local implementation of the bash executor seam over the subprocess
|
||||
* seam. Public commands run as `bash -c` in a managed process group spawned
|
||||
* Local Service provider for the bash capability seam over the subprocess
|
||||
* capability seam. Public commands run as `bash -c` in a managed process group spawned
|
||||
* through `ctx.subprocess`; subclasses may reuse the same mechanics with an
|
||||
* explicit argv. This executor owns command defaulting, deadlines and cause
|
||||
* classification, the model-friendly terminal environment, and the model-facing
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/bash-sandbox/README.md
|
||||
README.md: 74c1e28f76db35603bb9f72e0522e16ece5589f7
|
||||
README.zh.md: 2593049ce09c7bc1ae0a996321bb27cbac449977
|
||||
README.md: 44321d7ef26e9e4399438f61b3fdfbfa2e4d8c11
|
||||
README.zh.md: f3e110c049871a5a6121716307aaa3d1d1f3eedb
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) and a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (which owns the default mode + workspace root, shared with the sandboxed filesystem) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields.
|
||||
Sandbox-consuming Service provider for the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) and a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (which owns the default mode + workspace root, shared with the sandboxed filesystem) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields.
|
||||
|
||||
The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; result-classification helpers stay internal.
|
||||
|
||||
@@ -17,7 +17,7 @@ Every command is confined by handing the provider the exact `['bash', '-c', comm
|
||||
Semantics:
|
||||
|
||||
- **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI).
|
||||
- **Runner attribution is conservative.** Before a process starts, a rejection is attributed to the runner only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with positive provenance for provider argv[0]. This covers a missing runner, a non-executable runner, or an executable script whose shebang interpreter is unavailable. A bare `syscall: 'spawn'` without an exact error path, any other code, an invalid or unusable workdir, a resource failure, an unrelated syscall, or an unstructured rejection retains the local executor's command-start failure semantics. Foreground execution throws `SANDBOX_UNAVAILABLE` with the original spawn detail, while asynchronous background settlement stamps `runnerFailed: true` and `denied: false`. If a `SubprocessService` synchronously throws the same provenanced `ENOENT`/`EACCES` shape, background start throws `SANDBOX_UNAVAILABLE`; other synchronous errors propagate unchanged. After a process starts, a rule's optional exit-code gate and a remaining fatal stderr line must both match after exact informational-line exclusions. A match outranks denial; foreground execution throws `SANDBOX_UNAVAILABLE` with the matched fatal line, while a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Confined background handles retain their mode/enforcement facts and release per-process accounting in either path.
|
||||
- **The runner path or syscall must match.** Before a process starts, a rejection is attributed to the runner only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with either an `error.path` equal to provider argv[0] or, when `error.path` is absent, an exact `syscall: 'spawn <runner>'`. A present path also requires `syscall: 'spawn'` or the exact `spawn <runner>`. This covers a missing runner, a non-executable runner, or an executable script whose shebang interpreter is unavailable. A bare `syscall: 'spawn'` without an exact error path, any other code, an invalid or unusable workdir, a resource failure, an unrelated syscall, or an unstructured rejection retains the local executor's command-start failure semantics. Foreground execution throws `SANDBOX_UNAVAILABLE` with the original spawn detail, while asynchronous background settlement stamps `runnerFailed: true` and `denied: false`. If a `SubprocessService` synchronously throws the same runner-identifying `ENOENT`/`EACCES` shape, background start throws `SANDBOX_UNAVAILABLE`; other synchronous errors propagate unchanged. After a process starts, a rule's optional exit-code check and a remaining fatal stderr line must both match after exact informational-line exclusions. A match takes priority over denial; foreground execution throws `SANDBOX_UNAVAILABLE` with the matched fatal line, while a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Confined background handles retain their mode/enforcement facts and release per-process accounting in either path.
|
||||
- **Deployment fallback, per-call policy.** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) resolves a complete `SandboxExecutionPolicy` for every tool call: the calling session supplies its mode override and immutable cwd root, while deployment config supplies the fallbacks for agentless calls. An approved escalation changes only that policy's mode; its session root stays attached. `resolve()` carries the policy onto the spec, so overlapping commands from different projects run, classify, and report under their own roots and modes. The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted; the static bash tool description separately owns denial and escalation guidance.
|
||||
- **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce.
|
||||
- Process mechanics (spawn, process-group kills, output collection/spill, background handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/).
|
||||
@@ -70,7 +70,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). A runner-attributable spawn failure supplies the original spawn error as detail; a rejection without `ENOENT`/`EACCES` argv[0] evidence remains an ordinary command-start error. A settled runner failure supplies the matched fatal stderr line and preserves the original stderr collection. When present, the appended `Runner failure: <detail>` is the authoritative diagnosis; the preceding backend-install text is the generic `SANDBOX_UNAVAILABLE` prefix.
|
||||
If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). A runner-attributable spawn failure supplies the original spawn error as detail; a rejection without `ENOENT`/`EACCES` path or syscall evidence that names argv[0] remains an ordinary command-start error. A settled runner failure supplies the matched fatal stderr line and preserves the original stderr collection. When present, the appended `Runner failure: <detail>` is the authoritative diagnosis; the preceding backend-install text is the generic `SANDBOX_UNAVAILABLE` prefix.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -84,5 +84,5 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
- **Confinement covers file effects only** — network access and process visibility are unchanged, so the modes are not a general-purpose security sandbox.
|
||||
- **Denials are inferred from failed-command stderr** — backend signatures make the inference portable, but a matching application error can be classified as a denial and a denial omitted from the retained tail can be missed.
|
||||
- **An asynchronously observed background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `task_output`; a provenanced synchronous `SubprocessService` throw instead fails `start()` immediately.
|
||||
- **An asynchronously observed background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `task_output`; a synchronous `SubprocessService` throw that names the runner path instead fails `start()` immediately.
|
||||
- **`danger-full-access` deliberately bypasses `ctx.sandbox`** — it is an explicit unconfined mode, not a wider sandbox profile.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
这是使用沙箱能力的 [`@deepseek-ai/dsh-bash`](../bash/) 执行器 seam 实现。加载它时,应**用它替代** `@deepseek-ai/dsh-bash-local`,并同时加载 [`ctx.sandbox`](../../sandbox/sandbox/) 提供方(例如 [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/))及 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/);默认模式和工作区根目录由后者负责,并与受沙箱约束的文件系统共享这些设置。无需使用替代工具插件;`dsh-tool-bash` 会检测执行器的 `sandboxMode` 能力并添加升权字段。
|
||||
这是使用沙箱能力的 [`@deepseek-ai/dsh-bash`](../bash/) 执行器 seam Service provider。加载它时,应**用它替代** `@deepseek-ai/dsh-bash-local`,并同时加载 [`ctx.sandbox`](../../sandbox/sandbox/) 提供方(例如 [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/))及 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/);默认模式和工作区根目录由后者负责,并与受沙箱约束的文件系统共享这些设置。无需使用替代工具插件;`dsh-tool-bash` 会检测执行器的 `sandboxMode` 能力并添加升权字段。
|
||||
|
||||
包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`;结果分类 helper 保留在内部。
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
语义:
|
||||
|
||||
- **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言,即提供方在每次包装时加上的特征(bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM),则结果报告 `BashRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement`:`full`,或在较旧 Landlock ABI 上为 `partial`)。
|
||||
- **Runner 归因是保守的。** 进程启动前,只有当调用方拥有的 workdir 经独立验证可用,并且 Node 报告 `ENOENT` 或 `EACCES`,且带有明确指向提供方 argv[0] 的来源信息时,才会将拒绝归因于 runner。这样可以识别缺失的 runner、不可执行的 runner,或 shebang 解释器不可用的可执行脚本。没有精确错误路径的裸 `syscall: 'spawn'`、任何其他错误码、无效或不可用的 workdir、资源失败、无关 syscall 或无结构拒绝仍保留本地执行器的命令启动失败语义。前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,异步后台结算则会标记 `runnerFailed: true` 和 `denied: false`。如果 `SubprocessService` 同步抛出同样带有来源信息的 `ENOENT`/`EACCES` 形态,后台启动会抛出 `SANDBOX_UNAVAILABLE`;其他同步错误原样传播。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。无论走哪条路径,受限制的后台句柄都会保留自身的模式/强制执行事实,并释放每进程计数。
|
||||
- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent(智能体)调用提供回退。已批准的升权只更改该策略的模式,会话根目录仍然附着其上。`resolve()` 把策略带入 spec,因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权;静态 bash 工具描述则单独负责拒绝与升级引导。
|
||||
- **Runner 路径或 syscall 必须匹配。** 进程启动前,调用方拥有的 workdir 必须经独立验证可用,Node 必须报告 `ENOENT` 或 `EACCES`,并且错误必须符合以下一种形态:`error.path` 等于提供方返回的 `argv[0]`,同时 `syscall` 为 `'spawn'` 或精确的 `'spawn <runner>'`;或者 `error.path` 不存在,同时 `syscall` 为精确的 `'spawn <runner>'`。这样可以识别缺失的 runner、不可执行的 runner,或 shebang 解释器不可用的可执行脚本。没有精确错误路径的裸 `syscall: 'spawn'`、任何其他错误码、无效或不可用的 workdir、资源失败、无关 syscall 或无结构拒绝仍保留本地执行器的命令启动失败语义。前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,异步后台结算则会标记 `runnerFailed: true` 和 `denied: false`。如果 `SubprocessService` 同步抛出同样能指明 runner 的 `ENOENT`/`EACCES` 形态,后台启动会抛出 `SANDBOX_UNAVAILABLE`;其他同步错误原样传播。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码检查和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。无论走哪条路径,受限制的后台句柄都会保留自身的模式/强制执行事实,并释放每进程计数。
|
||||
- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent(智能体)调用提供回退。已批准的升权只更改该策略的模式,会话根目录仍然附着其上。`resolve()` 把策略带入 spec,因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权;静态 bash 工具描述则单独负责拒绝与升权引导。
|
||||
- **只限制文件影响。** 设计上不限制网络与进程可见性:模式词汇不会声称覆盖后端未强制执行的范围。
|
||||
- 进程机制(spawn、进程组终止、输出收集/spill、后台句柄、凭证清理)继承自 [`dsh-bash-local`](../bash-local/);runner 选择位于 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。可归因于 runner 的 spawn 失败会以原始 spawn 错误作为详细信息;没有 `ENOENT`/`EACCES` argv[0] 证据的拒绝仍是普通的命令启动错误。已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。如果追加了 `Runner failure: <detail>`,它就是权威诊断;前面的后端安装文本只是通用的 `SANDBOX_UNAVAILABLE` 前缀。
|
||||
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。判定为 runner 失败的 spawn 错误会以原始 spawn 错误作为详细信息;如果拒绝没有通过 `ENOENT`/`EACCES` 的 `path` 或 `syscall` 证据指明 `argv[0]`,它仍是普通的命令启动错误。已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。如果追加了 `Runner failure: <detail>`,它就是权威诊断;前面的后端安装文本只是通用的 `SANDBOX_UNAVAILABLE` 前缀。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -84,5 +84,5 @@
|
||||
|
||||
- **限制只覆盖文件影响**:网络访问与进程可见性不变,因此这些模式不是通用安全沙箱。
|
||||
- **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但包含相同后端特征的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。
|
||||
- **异步观测到的后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现;同步 `SubprocessService` 抛出带有来源信息的 `ENOENT`/`EACCES` 时,则会使 `start()` 立即失败。
|
||||
- **异步观测到的后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现;`SubprocessService` 同步抛出的错误包含 runner 路径时,则会使 `start()` 立即失败。
|
||||
- **`danger-full-access` 有意绕过 `ctx.sandbox`**:它是显式无约束模式,不是更宽的沙箱 profile。
|
||||
|
||||
@@ -23,7 +23,7 @@ function isUsableWorkdir(path: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribute only Node ENOENT/EACCES failures with positive argv[0] provenance
|
||||
* Attribute only Node ENOENT/EACCES failures whose error path equals argv[0]
|
||||
* after independently ruling out the caller-owned cwd. A supplied error path
|
||||
* must exactly identify the runner; without one, the syscall must. With a
|
||||
* usable cwd, these codes describe resolution or execute permission for that
|
||||
|
||||
@@ -44,7 +44,7 @@ export type Config = LocalConfig
|
||||
export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
static override inject = ['subprocess', 'sandbox', 'sandboxPolicy']
|
||||
|
||||
// No own Config: the sandbox default (mode + workspaceRoot) moved to
|
||||
// No own Config: the sandbox default (mode + workspaceRoot) is owned by
|
||||
// ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config
|
||||
// verbatim (the config catalog walks the inherited static).
|
||||
|
||||
@@ -124,7 +124,7 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
try {
|
||||
proc = this.startArgv(spec, confined.argv)
|
||||
} catch (error) {
|
||||
// LocalSubprocessService reports provenanced ENOENT/EACCES through async
|
||||
// LocalSubprocessService reports ENOENT/EACCES with the failed executable path through async
|
||||
// `done` rejection; this covers alternatives that throw that shape synchronously.
|
||||
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
|
||||
throw new SandboxUnavailableError(mode, String(error))
|
||||
|
||||
@@ -151,7 +151,7 @@ describe('partial Landlock runner-failure classification', () => {
|
||||
|
||||
// Node/libuv may expose execve's ENOEXEC directly (Darwin) or retry a
|
||||
// no-shebang executable through /bin/sh (Linux). Neither path supplies the
|
||||
// provenanced ENOENT/EACCES evidence required for runner attribution.
|
||||
// ENOENT/EACCES with the exact failed executable path required for runner attribution.
|
||||
const foreground = await bash.run(bash.resolve(request)).catch((value: unknown) => value)
|
||||
expect(foreground).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
|
||||
|
||||
@@ -240,7 +240,7 @@ describe('fail closed', () => {
|
||||
expect(background).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
})
|
||||
|
||||
it('classifies a synchronous SubprocessService EACCES with exact runner provenance', async () => {
|
||||
it('classifies a synchronous SubprocessService EACCES with the exact runner path', async () => {
|
||||
const runner = join(spillDir, 'unexecutable-runner')
|
||||
const { ctx, bash } = await setup({}, argv => ({
|
||||
argv: [runner, ...argv],
|
||||
@@ -439,7 +439,7 @@ describe('isRunnerSpawnFailure', () => {
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT'), undefined, process.cwd())).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts only syscall provenance compatible with the exact runner program', () => {
|
||||
it('accepts only syscall and error-path facts that identify the exact runner program', () => {
|
||||
const runner = join(spillDir, 'runner with spaces')
|
||||
const spawnError = (syscall: string, path?: string) =>
|
||||
Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall, path })
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/bash/README.md
|
||||
README.md: 88f519a21a0889d6b7649502c51077940c23709f
|
||||
README.zh.md: 294044692133da8baa57583146352e84c1ff9946
|
||||
README.md: 23b0acd096bb835ef57563337c91e5cf63b58677
|
||||
README.zh.md: 14ba749a0018bd6a63475bc0ab72c6fe6d26893a
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **bash executor seam**: an abstract `BashExecutor` service (`ctx.bash`) defining WHAT a bash backend does — run foreground commands and start background processes — without saying HOW. Task ids, ownership, collection, cancellation, and notices belong to the generic `ctx.tasks` runtime.
|
||||
The **`BashExecutor`** (`ctx.bash`) defines WHAT a bash backend does — run foreground commands and start background processes — without saying HOW. Task ids, ownership, collection, cancellation, and notices belong to the generic `ctx.tasks` runtime.
|
||||
|
||||
This package is the interface quarter of the bash capability, split so each concern can evolve (and be swapped) independently:
|
||||
This package owns the Service Definition role of the bash capability, split so each role can evolve (and be swapped) independently:
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-bash` (this) | the interface: abstract service + vocabulary types |
|
||||
| `@deepseek-ai/dsh-bash-local` | an implementation: local subprocesses |
|
||||
| `@deepseek-ai/dsh-bash-sandbox` | an implementation: `dsh-bash-local`'s mechanics with every spawn confined via [`ctx.sandbox`](../../sandbox/sandbox/), denials reported as result facts |
|
||||
| `@deepseek-ai/dsh-bash` (this) | Service Definition: abstract service + vocabulary types |
|
||||
| `@deepseek-ai/dsh-bash-local` | Service provider: local subprocesses |
|
||||
| `@deepseek-ai/dsh-bash-sandbox` | Service provider: `dsh-bash-local`'s mechanics with every spawn confined via [`ctx.sandbox`](../../sandbox/sandbox/), denials reported as result facts |
|
||||
| `@deepseek-ai/dsh-tool-bash` | the model-facing tool schemas over `ctx.bash` |
|
||||
|
||||
The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool survey: pi hides execution behind a `BashOperations` interface (local shell / SSH / VM backends), Codex behind an exec-server protocol. `dsh-bash-sandbox` is exactly that swap in action — a sandboxing executor behind the same interface; the consumer detects its `sandboxMode` capability and adds escalation fields without importing the implementation. A containerized or remote executor slots in the same way.
|
||||
The split is a standard capability seam ([capability-seams Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): `dsh-bash-sandbox` is a sandboxing executor behind the same Service Definition — the Consumer detects its `sandboxMode` capability and adds escalation fields without importing the provider — and a containerized or remote executor slots in the same way.
|
||||
|
||||
## Service API (`ctx.bash`)
|
||||
|
||||
@@ -31,11 +31,11 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp
|
||||
|
||||
`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxPolicy?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxPolicy) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxPolicy` is optional on the request and required-but-nullable on the resolved spec: it carries the complete per-call mode and workspace root. The sandbox tool path resolves it from the calling session through `ctx.sandboxPolicy`; a direct sandbox-executor caller falls back to deployment policy, while a non-sandboxing executor carries the field and confines nothing.
|
||||
|
||||
The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md).
|
||||
The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [subsystems/bash.md](../../../docs/subsystems/bash.md).
|
||||
|
||||
`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, then merge `dshEnv` after ordinary `env`, so an omitted current fact cannot fall back to stale ambient state and an `env` entry cannot displace a managed value. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
|
||||
The exported `parseExitStatus` (with `ParsedExitStatus`) is the shared rendering contract half of the shell tools: the inverse of the `[exit code: N]` / `[killed by signal: X]` markers `dsh-tool-bash`'s `renderResult` and `dsh-tool-pwsh`'s `renderPwshResult` append. Both tools' `presentResult` use it to split the rendered text into the terminal card's output body and its exit-status pill; it lives on the seam so the two tools never drift on the marker contract.
|
||||
The exported `parseExitStatus` (with `ParsedExitStatus`) is the shared rendering contract half of the shell tools: the inverse of the `[exit code: N]` / `[killed by signal: X]` markers `dsh-tool-bash`'s `renderResult` and `dsh-tool-pwsh`'s `renderPwshResult` append. Both tools' `presentResult` use it to split the rendered text into the terminal card's output body and its exit-status pill; it lives with the Service Definition so the two tools never drift on the marker contract.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
**bash 执行器 seam**:抽象 `BashExecutor` 服务(`ctx.bash`)定义 bash 后端做什么,即运行前台命令与启动后台进程,但不规定如何实现。task id、所有权、收集、取消与通知属于通用 `ctx.tasks` 运行时。
|
||||
**`BashExecutor`**(`ctx.bash`)定义 bash 后端做什么,即运行前台命令与启动后台进程,但不规定如何实现。task id、所有权、收集、取消与通知属于通用 `ctx.tasks` 运行时。
|
||||
|
||||
本包是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换):
|
||||
本包承担 bash 能力的 Service Definition 角色,各角色因此可以独立演进(和替换):
|
||||
|
||||
| 包 | 职责 |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-bash`(本包) | 接口:抽象服务 + 词汇类型 |
|
||||
| `@deepseek-ai/dsh-bash-local` | 实现:本地子进程 |
|
||||
| `@deepseek-ai/dsh-bash-sandbox` | 实现:沿用 `dsh-bash-local` 的机制,但通过 [`ctx.sandbox`](../../sandbox/sandbox/) 限制每次 spawn,并将拒绝报告为结果事实 |
|
||||
| `@deepseek-ai/dsh-bash`(本包) | Service Definition:抽象服务 + 词汇类型 |
|
||||
| `@deepseek-ai/dsh-bash-local` | Service provider:本地子进程 |
|
||||
| `@deepseek-ai/dsh-bash-sandbox` | Service provider:沿用 `dsh-bash-local` 的机制,但通过 [`ctx.sandbox`](../../sandbox/sandbox/) 限制每次 spawn,并将拒绝报告为结果事实 |
|
||||
| `@deepseek-ai/dsh-tool-bash` | 基于 `ctx.bash`、面向模型的工具 schema |
|
||||
|
||||
该拆分与 LLM(大语言模型) seam(`LlmService`/`LlmAdapter`)及 agent(智能体)工具调研结果一致:pi 将执行隐藏在 `BashOperations` 接口之后(本地 shell/SSH/VM 后端),Codex 则隐藏在 exec-server 协议之后。`dsh-bash-sandbox` 正是这种替换的实际应用:沙箱执行器位于同一接口之后;消费方检测其 `sandboxMode` 能力并添加升权字段,无需导入实现。容器化或远程执行器也可以同样接入。
|
||||
该拆分是一个标准的能力 seam([capability-seams Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):`dsh-bash-sandbox` 是位于同一 Service Definition 之后的沙箱执行器——Consumer 检测其 `sandboxMode` 能力并添加升权字段,无需导入提供方——容器化或远程执行器也可以同样接入。
|
||||
|
||||
## 服务 API(`ctx.bash`)
|
||||
|
||||
@@ -31,11 +31,11 @@
|
||||
|
||||
`BashExecRequest`(command、workdir?、timeoutMs?、stdoutMaxBytes?、signal?、stdin?、env?、dshEnv?、sandboxPolicy?)在执行前解析为 `BashExecSpec`(command、workdir、timeoutMs、stdoutMaxBytes、signal?、stdin?、env?、dshEnv?、sandboxPolicy)。`stdoutMaxBytes` 是受信任前台运行的捕获预算,用于必须解析完整有界 stdout 的消费方;面向模型的 bash 工具不公开该字段。`sandboxPolicy` 在请求上可选,在已解析 spec 上必填但可为 null:它携带完整的每次调用模式与工作区根目录。沙箱工具路径通过 `ctx.sandboxPolicy` 从调用会话解析它;沙箱执行器的直接调用方回退到部署策略,非沙箱执行器则携带该字段但不作限制。
|
||||
|
||||
每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult`;`start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md)。
|
||||
每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult`;`start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [subsystems/bash.md](../../../docs/subsystems/bash.md)。
|
||||
|
||||
`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
|
||||
导出的 `parseExitStatus`(连同 `ParsedExitStatus`)是 shell 工具共享渲染契约的另一半:`dsh-tool-bash` 的 `renderResult` 与 `dsh-tool-pwsh` 的 `renderPwshResult` 追加的 `[exit code: N]`/`[killed by signal: X]` marker 的逆解析。两个工具的 `presentResult` 都用它把渲染文本拆成 terminal 卡的输出正文与其退出状态 pill;它放在 seam 上,两个工具便永远不会在 marker 契约上漂移。
|
||||
导出的 `parseExitStatus`(连同 `ParsedExitStatus`)是 shell 工具共享渲染约定的另一半:`dsh-tool-bash` 的 `renderResult` 与 `dsh-tool-pwsh` 的 `renderPwshResult` 追加的 `[exit code: N]`/`[killed by signal: X]` marker 的逆解析。两个工具的 `presentResult` 都用它把渲染文本拆成 terminal 卡的输出正文与其退出状态 pill;它放在 Service Definition 中,两个工具便永远不会在 marker 约定上漂移。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The `ctx.bash` executor seam for foreground commands and background process
|
||||
* Service Definition for the `ctx.bash` capability seam, covering foreground commands and background process
|
||||
* handles. Task ids, ownership, polling, and notices belong to
|
||||
* `@deepseek-ai/dsh-tasks`, keeping executors independent of sessions.
|
||||
* @module @deepseek-ai/dsh-bash
|
||||
|
||||
@@ -10,7 +10,7 @@ export const name = 'bash-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** No runtime invariant: this stateless seam owns request/result types, while executors and policy own observations. */
|
||||
/** No runtime invariant: this stateless Service Definition owns request/result types, while executors and policy own observations. */
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/pwsh-local/README.md
|
||||
README.md: 3e38ea3830cb651a80eaee744a42f68891767358
|
||||
README.zh.md: 8d32ce865d299bac37704e3e8730a7faa63ee108
|
||||
README.md: eb3365b009e3595230e5fb0f616079bd73c55840
|
||||
README.zh.md: d79201c756a26bbc343e2b284a803b0cf9aee69b
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Local PowerShell implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `PwshLocalExecutor` spawns `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` per call as a managed process through `ctx.subprocess`, and owns everything PowerShell-shaped — executable resolution, command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's.
|
||||
Local PowerShell Service provider for the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `PwshLocalExecutor` spawns `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` per call as a managed process through `ctx.subprocess`, and owns everything PowerShell-shaped — executable resolution, command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's.
|
||||
|
||||
The command string rides as ONE argv element to `-Command`: PowerShell itself parses the text, and no intermediate shell exists, so there is no shell-quoting layer to escape (the `bash -c` string domain has no equivalent here). Native Win32 paths (`C:\...`) pass through unchanged.
|
||||
|
||||
@@ -23,7 +23,7 @@ The package root exports the default and named `PwshLocalExecutor` plugin, its `
|
||||
pwshPath: C:\Program Files\PowerShell\7\pwsh.exe # explicit executable; else well-known locations, then PATH
|
||||
```
|
||||
|
||||
## Behavior (and where it came from)
|
||||
## Behavior
|
||||
|
||||
The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantics call-for-call:
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
`@deepseek-ai/dsh-bash` 执行器 seam 的本地 PowerShell 实现,基于 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务:`PwshLocalExecutor` 每次调用以受管进程的方式通过 `ctx.subprocess` spawn `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>`,并拥有所有 PowerShell 形状的职责——可执行文件解析、命令默认化与上限、超时/取消分类、面向模型的终端环境,以及后台读取的 stdout/stderr 合并。进程组机制(有界 spill 输出、凭据清理、终止升级、销毁)属于 subprocess 服务。
|
||||
`@deepseek-ai/dsh-bash` 执行器 seam 的本地 PowerShell Service provider,基于 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务:`PwshLocalExecutor` 每次调用以受管进程的方式通过 `ctx.subprocess` spawn `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>`,并拥有所有 PowerShell 形状的职责——可执行文件解析、命令默认化与上限、超时/取消分类、面向模型的终端环境,以及后台读取的 stdout/stderr 合并。进程组机制(有界 spill 输出、凭据清理、终止升级、销毁)属于 subprocess 服务。
|
||||
|
||||
命令字符串作为 ONE argv 元素传给 `-Command`:由 PowerShell 自己解析文本,不存在中间 shell,因此没有需要转义的 shell 引号层(`bash -c` 字符串域在这里没有对应物)。原生 Win32 路径(`C:\...`)原样通过。
|
||||
命令字符串作为 ONE argv 元素传给 `-Command`:由 PowerShell 自己解析文本,不存在中间 shell,因此没有需要转义的 shell 引号层(这里不存在与 `bash -c` 字符串域对应的层)。原生 Win32 路径(`C:\...`)原样通过。
|
||||
|
||||
包根导出默认与具名 `PwshLocalExecutor` 插件、其 `Config`、纯函数 `resolvePwshPath`/`candidatePwshPaths` 辅助函数,以及执行器注入每次 spawn 的 `ENV_OVERRIDES`/`ENCODING_PREAMBLE` 常量。
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
pwshPath: C:\Program Files\PowerShell\7\pwsh.exe # explicit executable; else well-known locations, then PATH
|
||||
```
|
||||
|
||||
## 行为(及其由来)
|
||||
## 行为
|
||||
|
||||
作为 `dsh-bash-local` 的 Windows 对应物,逐调用地镜像其语义:
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
- **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding` 与 `$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出:subprocess collector 以 UTF-8 解码字节。输入编码保持宿主默认;pwsh 7 默认为 UTF-8,不受影响。
|
||||
- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数,在构造时执行一次。
|
||||
- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。
|
||||
- **超时与取消分类**——`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。Windows 将强制终止报告为退出码 1 且无信号,因此基于信号的实情(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。
|
||||
- **超时与取消分类**——`run()` 通过一个 deadline 融合按配置上限截取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。Windows 将强制终止报告为退出码 1 且无信号,因此带信号标记的事实(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。
|
||||
- **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。
|
||||
- **后台进程**——`start()` 立即返回存活的 `BashProcess` 句柄,不设超时;句柄的 `readOutput()` 把服务基于偏移的 stdout/stderr 读取合并为带标记分段的增量与消费游标。仍在运行的进程属于 subprocess 服务,因此它跨执行器重载存活,并随服务销毁(被终止并 join)。一切任务形状的职责(id、所有权、轮询、通知)都在通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md) 中,由工具层把句柄注册进去——本执行器从不接触会话或注册表。
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Local PowerShell implementation of the bash executor seam. Each command runs
|
||||
* Local PowerShell Service provider for the bash capability seam. Each command runs
|
||||
* as `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` in a managed
|
||||
* process spawned through `ctx.subprocess`; the executor owns command
|
||||
* defaulting, deadlines and cause classification, the model-friendly terminal
|
||||
@@ -279,7 +279,7 @@ export class PwshLocalExecutor extends BashExecutor {
|
||||
* Settlement hook for subclasses that attach execution facts to a process.
|
||||
* The base implementation is intentionally empty. Mirrored from
|
||||
* `dsh-bash-local` (whose sandboxing subclass consumes the same hook); it is
|
||||
* the declared seam for a future pwsh-confining subclass and has no consumer
|
||||
* the protected extension point for a future pwsh-confining subclass and has no consumer
|
||||
* in this package yet.
|
||||
* @param _proc - the settled process handle.
|
||||
* @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
|
||||
|
||||
@@ -119,6 +119,8 @@ describe('spawn construction (pure, every platform)', () => {
|
||||
/** A subprocess service that records spawn specs and settles instantly. */
|
||||
class CapturingSubprocessService extends SubprocessService {
|
||||
specs: SubprocessSpawnSpec[] = []
|
||||
override async resolveExecutable(command: string): Promise<string> { return command }
|
||||
override spawnTerminal(): Promise<never> { throw new Error('pwsh spawns pipes, never terminals') }
|
||||
private readonly reader: SubprocessOutputReader = {
|
||||
readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }),
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/tool-bash/README.md
|
||||
README.md: 47101e1198d13518c3d82877df8726c1fbf26b82
|
||||
README.zh.md: d60ac4b3826838e875f7d43bc314f62e1453c1d9
|
||||
README.md: 9e2cd0c2ed999a11dcbffcd99a1d0cb672367905
|
||||
README.zh.md: 8a1e7ba0af36ffd891b38491075ee75283fdbed5
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
The model-facing `bash` tool registered over the `ctx.bash` executor seam. Foreground execution stays behind that seam; a background process handle is registered with the generic `ctx.tasks` runtime and controlled through `task_output`, `task_list`, and `task_kill` from `@deepseek-ai/dsh-tool-tasks`.
|
||||
|
||||
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`) and the [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) registry; the plugin stays pending until every injected service exists (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`). The tool contract is bash-dialect — mount a bash-parsing executor.
|
||||
Requires a loaded executor Service provider (e.g. `@deepseek-ai/dsh-bash-local`) and the [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) registry; the plugin stays pending until every injected service exists (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`). The tool contract is bash-dialect — mount a bash-parsing executor.
|
||||
|
||||
The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering and background-process adaptation remain package-internal.
|
||||
|
||||
@@ -24,7 +24,7 @@ The plugin also contributes the `tool:bash` prompt section (order 105): check th
|
||||
| `sandbox_permissions` | string enum | ADVERTISED ONLY when the mounted executor sandboxes (`ctx.bash.sandboxMode` reports a confining default): the wider mode a denied command needs, from the closed target vocabulary `workspace-write`/`danger-full-access` (never cut down to the executor's default — the effective mode is per-session; strict widening is checked at execution against it, and a non-widening request fails without prompting anyone). |
|
||||
| `justification` | string | Required together with `sandbox_permissions` (each without the other is a validation error): one sentence for the user explaining why this exact command needs the wider access. |
|
||||
|
||||
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution, so the executor seam (`BashExecSpec`) receives explicit `workdir`/`timeoutMs` values. The workdir default is applied in the tool layer from the calling agent's `session.header.cwd` BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`. When sandbox policy is present, the tool reuses its already-canonical `workspaceRoot` as the workdir base so confinement and process launch cannot resolve the same session spelling differently.
|
||||
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution, so the Service Definition (`BashExecSpec`) receives explicit `workdir`/`timeoutMs` values. The workdir default is applied in the tool layer from the calling agent's `session.header.cwd` BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`. When sandbox policy is present, the tool reuses its already-canonical `workspaceRoot` as the workdir base so confinement and process launch cannot resolve the same session spelling differently.
|
||||
|
||||
### Managed shell environment
|
||||
|
||||
@@ -42,7 +42,7 @@ The tool owns its `presentCall`/`presentResult` render intent. A foreground call
|
||||
|
||||
## The tool builds its request from named args only
|
||||
|
||||
The `BashExecRequest` seam carries optional `stdoutMaxBytes`, `stdin`, ordinary `env`, and managed `dshEnv`, used by trusted in-process plugins and this tool's environment registry. The model-facing tool exposes none of `stdoutMaxBytes`, `stdin`, or `env`: it builds requests from named command/workdir/timeout/signal/sandbox fields plus the registry-collected `dshEnv`. Extra model keys are ignored and cannot replace managed values. Shell syntax provides equivalent command-level behavior, while the local executor scrubs ambient credentials and stale `DSH_*` values. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
`BashExecRequest` carries optional `stdoutMaxBytes`, `stdin`, ordinary `env`, and managed `dshEnv`, used by trusted in-process plugins and this tool's environment registry. The model-facing tool exposes none of `stdoutMaxBytes`, `stdin`, or `env`: it builds requests from named command/workdir/timeout/signal/sandbox fields plus the registry-collected `dshEnv`. Extra model keys are ignored and cannot replace managed values. Shell syntax provides equivalent command-level behavior, while the local executor scrubs ambient credentials and stale `DSH_*` values. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
|
||||
## Permissions and escalation
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
模型侧 `bash` 工具,注册在 `ctx.bash` 执行器 seam 上。前台执行始终位于该 seam 之后;后台进程句柄会注册到通用 `ctx.tasks` 运行时,并通过 `task_output`、`task_list` 和 `task_kill` 控制;这些工具由 `@deepseek-ai/dsh-tool-tasks` 提供。
|
||||
|
||||
需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`)与 [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) 注册表;在每个注入服务就绪之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。工具契约是 bash 方言——请挂载能解析 bash 的执行器。
|
||||
需要加载执行器 Service provider(例如 `@deepseek-ai/dsh-bash-local`)与 [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) 注册表;在每个注入服务就绪之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。工具约定是 bash 方言——请挂载能解析 bash 的执行器。
|
||||
|
||||
包(package)根只公开 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染和后台进程适配仍保留在包内部。
|
||||
包(package)根只公开 Cordis 插件约定(`name`、`inject`、`Config`、`apply`);结果渲染和后台进程适配仍保留在包内部。
|
||||
|
||||
插件还会提供 `tool:bash` 提示词段落(顺序 105):检查每个结果中的 `[exit code: N]` 标记,发现失败时先调查原因再继续。
|
||||
|
||||
@@ -24,11 +24,11 @@
|
||||
| `sandbox_permissions` | string enum | 仅当已挂载的执行器启用沙箱时才会公开(`ctx.bash.sandboxMode` 报告一个具有限制作用的默认值):被拒命令所需的更宽模式,取自封闭的目标词汇 `workspace-write`/`danger-full-access`(绝不能缩减为执行器默认值;有效模式按会话确定,执行时会基于它检查是否严格拓宽,未拓宽的请求直接失败,不会向任何人发起提示)。 |
|
||||
| `justification` | string | 必须与 `sandbox_permissions` 一同提供(缺少任一项都会产生验证错误):用一句话向用户解释此命令为何需要这项更宽权限。 |
|
||||
|
||||
执行前,`command`、`workdir` 和 `timeoutMs` 会通过 `ctx.bash.resolve()` 依据执行器配置默认值完成解析,因此执行器 seam(`BashExecSpec`)收到显式的 `workdir`/`timeoutMs` 值。工具层会根据调用方 agent 的 `session.header.cwd` 应用工作目录默认值,然后才调用 `resolve()`:由于 N 个会话共享一个执行器,逐会话 cwd 必须来自 `exec.agent`;只有无法取得会话 cwd 时,执行器才回退到自身配置/`process.cwd()`。存在沙箱策略时,工具会复用已经规范化的 `workspaceRoot` 作为工作目录基准,防止限制逻辑与进程启动过程对同一个会话路径拼写产生不同解析结果。
|
||||
执行前,`command`、`workdir` 和 `timeoutMs` 会通过 `ctx.bash.resolve()` 依据执行器配置默认值完成解析,因此 Service Definition(`BashExecSpec`)收到显式的 `workdir`/`timeoutMs` 值。工具层会根据调用方 agent 的 `session.header.cwd` 应用工作目录默认值,然后才调用 `resolve()`:由于 N 个会话共享一个执行器,逐会话 cwd 必须来自 `exec.agent`;只有无法取得会话 cwd 时,执行器才回退到自身配置/`process.cwd()`。存在沙箱策略时,工具会复用已经规范化的 `workspaceRoot` 作为工作目录基准,防止限制逻辑与进程启动过程对同一个会话路径拼写产生不同解析结果。
|
||||
|
||||
### 托管 shell 环境
|
||||
|
||||
每次模型发起的前台或后台 bash 调用都会通过共享的 [`dsh-bash-env`](../bash-env/README.md) 注册表收到新收集的一组可信 `DSH_*` 环境变量:`DSH_HOME`(Harness home 绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及当活跃持久化后端能定位时的 `DSH_SESSION_JSONL`。注册表契约——贡献方注册、重复/未声明键的响亮失败、内置项保留与贡献方示例——住在该包的 README 里。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;本地执行器会先删除继承的所有 `DSH_*` 再合并,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份,且绝不修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。
|
||||
每次模型发起的前台或后台 bash 调用都会通过共享的 [`dsh-bash-env`](../bash-env/README.md) 注册表收到新收集的一组可信 `DSH_*` 环境变量:`DSH_HOME`(Harness home 绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及当活跃持久化后端能定位时的 `DSH_SESSION_JSONL`。注册表约定——贡献方注册、重复/未声明键的响亮失败、内置项保留与贡献方示例——住在该包的 README 里。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;本地执行器会先删除继承的所有 `DSH_*` 再合并,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份,且绝不修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。
|
||||
|
||||
结果文本依次包含 stdout、可选的 `[stderr]` 段落和适用的沙箱拒绝、超时、信号、退出代码及截断标记。超时与最终退出状态分别报告;非零退出仍是由模型解释的结果,不会成为 `isError`。截断结果会链接安全的完整 spill 文件,或报告文件不可用。只有 spawn 错误和中止等基础设施故障才会产生 `isError`。
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
## 工具仅使用具名参数构建请求
|
||||
|
||||
`BashExecRequest` seam 携带可选的 `stdoutMaxBytes`、`stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes`、`stdin` 或 `env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略,无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。
|
||||
`BashExecRequest` 携带可选的 `stdoutMaxBytes`、`stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes`、`stdin` 或 `env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略,无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。
|
||||
|
||||
## 权限与升权
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
## 逐会话模式切换
|
||||
|
||||
对于启用沙箱的执行器,每次调用依次按单次升权、会话覆盖、执行器默认值解析模式。未启用沙箱以及没有 agent 的调用不携带会话覆盖。策略归属方贡献当前且不区分具体能力的常驻模式;拒绝结果仍负责操作特定的有效模式与重试引导。参见 [`dsh-bash` 整合](../bash/README.md)和[沙箱切换契约](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
|
||||
对于启用沙箱的执行器,每次调用依次按单次升权、会话覆盖、执行器默认值解析模式。未启用沙箱以及没有 agent 的调用不携带会话覆盖。策略归属方贡献当前且不区分具体能力的常驻模式;拒绝结果仍负责操作特定的有效模式与重试引导。参见 [`dsh-bash` 整合](../bash/README.md)和[沙箱切换约定](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Model-facing `bash` tool over the `ctx.bash` executor seam. Background calls
|
||||
* Model-facing Consumer of the `ctx.bash` capability seam. Background calls
|
||||
* register process handles with `ctx.tasks`; their work uses task cancellation
|
||||
* rather than the tool-call signal after an id is returned.
|
||||
*
|
||||
@@ -155,7 +155,7 @@ function resolveWorkdir(
|
||||
return modelWorkdir
|
||||
}
|
||||
|
||||
/** Detach the executor DTO from readonly seam interfaces into plain JSON data. */
|
||||
/** Detach the executor DTO from readonly Service Definition types into plain JSON data. */
|
||||
function canonicalBashResult(result: BashRunResult) {
|
||||
const output = (stream: BashRunResult['stdout']) => ({
|
||||
text: stream.text,
|
||||
@@ -206,9 +206,9 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
* {@link approveEscalation}. This tool contributes only the composition
|
||||
* guard (the fields are unadvertised without a sandboxing executor, yet
|
||||
* schema validation checks advertised keys only, so an unadvertised
|
||||
* `sandbox_permissions` still reaches execute) and the approval ingredients
|
||||
* The shared policy resolver is required whenever the executor advertises
|
||||
* confinement, so a split composition fails at tool-plugin load.
|
||||
* `sandbox_permissions` still reaches execute) and the approval
|
||||
* ingredients. The shared policy resolver is required whenever the executor
|
||||
* advertises confinement, so a split composition fails at tool-plugin load.
|
||||
*/
|
||||
const approveBashEscalation = (
|
||||
mode: string,
|
||||
|
||||
@@ -19,7 +19,7 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent
|
||||
|
||||
/**
|
||||
* Full-loop integration: a scripted mock model drives the REAL bash tool
|
||||
* through the agent loop, exercising the same seams a live model would
|
||||
* through the agent loop, exercising the same execution paths a live model would
|
||||
* (tool/call + tool/result session events, the generic `ctx.tasks` runtime,
|
||||
* agent.inject completion notices).
|
||||
*/
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"path": "../../bash/bash-env"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
"path": "../../interaction/user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/tool-pwsh/README.md
|
||||
README.md: 78eb161f77b9524bc577b273abe59db6b931727c
|
||||
README.zh.md: 17696fe6d908838aaaca12e8179f2ad9cb780210
|
||||
README.md: 7d8ee5fb69b71d8e8707d3e4ed07ebdda99f799f
|
||||
README.zh.md: 40984bbc36be4b5809e6ee4db21e52d842f50cdb
|
||||
|
||||
@@ -119,6 +119,6 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No sandbox escalation** — `sandbox_permissions`/`justification` are absent; escalation waits for a Windows-confining executor (the bash tool's sandbox surface is not mirrored).
|
||||
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work.
|
||||
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only.
|
||||
- **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation.
|
||||
- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity; only the sandbox-less case applies here.
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
注册在 `ctx.bash` 执行器 seam 之上的模型可见 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具契约是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐、减去 sandbox 面——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `bash-env` 注册表管理 `DSH_*` 环境、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。
|
||||
注册在 `ctx.bash` 执行器 seam 之上的模型可见 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具约定是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐、减去 sandbox 面——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `bash-env` 注册表管理 `DSH_*` 环境、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。
|
||||
|
||||
需要已加载的执行器实现与 `bash-env` 插件;两者都存在前工具保持 pending(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。
|
||||
|
||||
包根只导出 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染(`src/render.ts`)与后台任务适配(`src/background.ts`)镜像 bash 工具的结构,并可通过包的 `./src/*` 导出访问。
|
||||
包根只导出 Cordis 插件约定(`name`、`inject`、`Config`、`apply`);结果渲染(`src/render.ts`)与后台任务适配(`src/background.ts`)镜像 bash 工具的结构,并可通过包的 `./src/*` 导出访问。
|
||||
|
||||
插件还贡献 `tool:pwsh` prompt section(order 105):非零退出以 `[exit code: N]` marker 报告,Windows 上的中断以无 signal 的 exit 1 结算。
|
||||
|
||||
@@ -119,6 +119,6 @@ ack 是固定短行;任务输出按读取有界。
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **无 sandbox 升级** — 没有 `sandbox_permissions`/`justification`;升级等待 Windows-confining 执行器(bash 工具的 sandbox 面不被镜像)。
|
||||
- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。
|
||||
- **PowerShell 方言契约** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。
|
||||
- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端仅限 Linux/macOS。
|
||||
- **PowerShell 方言约定** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。
|
||||
- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份;此处只涉及无 sandbox 场景。
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { BashProcess } from '@deepseek-ai/dsh-bash'
|
||||
export function processOutcome(proc: BashProcess): { status: 'completed' | 'killed'; detail: string } {
|
||||
// TODO(background-infrastructure-outcome): widen BashProcess with an explicit
|
||||
// infrastructure-failure outcome, then map spawn failures and
|
||||
// sandbox.runnerFailed to task `failed`. The current seam aliases a spawn
|
||||
// sandbox.runnerFailed to task `failed`. The current contract aliases a spawn
|
||||
// failure with a signal-less kill and a runner failure with an ordinary
|
||||
// wrapper exit; real nonzero command exits must remain `completed`.
|
||||
if (proc.status === 'killed') {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Model-facing `pwsh` tool over the `ctx.bash` executor seam. Intended for
|
||||
* Model-facing PowerShell Consumer of the `ctx.bash` capability seam. Intended for
|
||||
* Windows compositions where a PowerShell executor (e.g.
|
||||
* `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is
|
||||
* PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables.
|
||||
@@ -112,7 +112,7 @@ function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent
|
||||
return modelWorkdir
|
||||
}
|
||||
|
||||
/** Detach the executor DTO from readonly seam interfaces into plain JSON data. */
|
||||
/** Detach the executor DTO from readonly Service Definition types into plain JSON data. */
|
||||
function canonicalPwshResult(result: BashRunResult): PwshForegroundResult {
|
||||
const output = (stream: BashRunResult['stdout']) => ({
|
||||
text: stream.text,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ui/README.md
|
||||
README.md: 15754410a4a81eb3fc898dd55269ddd1637e1dab
|
||||
README.zh.md: 4023b80085998f57ed321bfda3a0abdd08b70a28
|
||||
# pnpm run verify-translation-pairing --write packages/boot/README.md
|
||||
README.md: 5e4e483b60adab0b22ddb5279f4cd8fb699b9c35
|
||||
README.zh.md: 95a3f98129a7d1fdfaffb3cac6fed77bab7cff56
|
||||
11
packages/boot/README.md
Normal file
11
packages/boot/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# boot/ — shared app-bin boot glue
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The channel-neutral boot library the app bins share: `apps/cli`, the [`scaffold/`](../scaffold/README.md) launcher, and the [`examples/`](../examples/README.md) demo bins all consume it.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
|
||||
|
||||
The boot sequence and personal-config contract are documented in [`app-boot/README.md`](app-boot/README.md).
|
||||
11
packages/boot/README.zh.md
Normal file
11
packages/boot/README.zh.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# boot/:共享的 app bin 启动粘合层
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
各 app bin 共享的、与渠道无关的启动库:`apps/cli`、[`scaffold/`](../scaffold/README.md) 启动器与 [`examples/`](../examples/README.md) demo bin 都消费它。
|
||||
|
||||
| 包 | 职责 | ctx 键 |
|
||||
|---|---|---|
|
||||
| `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、会明确报错的 Loader 保护机制、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) |
|
||||
|
||||
启动序列与个人配置约定见 [`app-boot/README.md`](app-boot/README.md)。
|
||||
6
packages/boot/app-boot/README.i18n.yaml
Normal file
6
packages/boot/app-boot/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
|
||||
README.md: 1bbd376121ae79bf37376b51f5ac3eb405af6dfd
|
||||
README.zh.md: 15263d8de9b69fc976ce328a6056e1b35e9beda5
|
||||
@@ -19,7 +19,7 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md) and [`ds
|
||||
| `watchUserPatches(ctx, options)` | Register the named patch file with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current user layer) and returns an async disposer |
|
||||
| `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile machinery (see [Profiles](#profiles)) |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error |
|
||||
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of same-provenance rows is preceded by a `# ==` comment naming the contributing file and the layers that patched it, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw |
|
||||
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of rows from the same file and patch layers is preceded by a `# ==` comment naming them, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
|
||||
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
|
||||
|
||||
@@ -33,7 +33,7 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](..
|
||||
|
||||
## Profiles
|
||||
|
||||
A profile is a directory under `$DSH_HOME/profiles/<name>` (the Harness home resolves through [`resolveDshHome`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list — and the user's own `cordis.patch.yml`. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps can never drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory — one symlink per package the installation's app and bundles depend on — so bare plugin names in any profile resolve through Node's ordinary parent-walk without pnpm ever managing in-box packages. `PROFILE_TEMPLATES` (`web`, `headless`) auto-initialize on first use; other names fail loud until `initProfile` creates them (the `dsh plugin` path).
|
||||
A profile is a directory under `$DSH_HOME/profiles/<name>` (the Harness home resolves through [`resolveDshHome`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list — and the user's own `cordis.patch.yml`. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory — one symlink per package the installation's app and bundles depend on — so bare plugin names in any profile resolve through Node's ordinary parent-walk without pnpm managing in-box packages. `PROFILE_TEMPLATES` (`web`, `headless`) auto-initialize on first use; other names fail loud until `initProfile` creates them (the `dsh plugin` path). `loadProfile` normalizes an exact installation-owned bundle tuple to its shipped template while preserving every other manifest field; any extra, missing, or reordered entry makes the list user-owned and leaves it unchanged.
|
||||
|
||||
User-level machine-local preferences also live in the Harness home:
|
||||
|
||||
@@ -2,45 +2,45 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
供 app bin([`dsh`](../../../apps/cli/README.md) 与 [`dsh-acp-demo`](../../examples/acp-demo/README.md))共用的启动粘合层:每个 bin 都是在这些 helper 上构建的精简自执行组合,并以自身诊断前缀参数化。这样,Loader 故障行为只由一处负责,不会在已发布产物之间逐渐分化。
|
||||
供 app bin([`dsh`](../../../apps/cli/README.md) 与 [`dsh-acp-demo`](../../examples/acp-demo/README.md))共用的启动粘合层:每个 bin 都是在这些辅助函数之上构建的精简自执行组合,并以自身诊断前缀参数化。这样,Loader 故障行为只由一处负责,不会在已发布产物之间逐渐分化。
|
||||
|
||||
| 导出 | 职责 |
|
||||
|---|---|
|
||||
| `resolveConfigPath(path, snapshotMode, cwd?)` | 生成绝对配置路径;当 `snapshotMode === 'replay'` 时,把 basename 为 `cordis.yml`/`.yaml` 的文件替换为同级 `cordis.snapshot.yml` |
|
||||
| `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) |
|
||||
| `loadLayeredEnv(binName, cwd?, warn?)` | 构建产品 CLI(命令行界面)冻结的「继承环境 > 项目 `.env` > 用户 `.env`」快照,拒绝文件中的 bootstrap-only 变量,并在不替换继承值的前提下物化其余文件值 |
|
||||
| `installFailLoud(binName, proc?, release?)` | 将启动期或后续未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;两者之间会等待可选的 `release` 拆卸回调(以 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 为上限),使持有终端的界面能在退出前恢复终端;返回卸载函数 |
|
||||
| `FAIL_LOUD_RELEASE_TIMEOUT_MS` | `installFailLoud` 等待其 `release` 回调的时长;卡住的 disposer 只会延迟致命退出,而不会取消它 |
|
||||
| `installFailLoud(binName, proc?, release?)` | 将启动期或后续未处理的 Loader 拒绝转换为一行带标签的 stderr 消息并执行 `exit(1)`;两者之间会等待可选的 `release` 拆卸回调(以 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 为上限),使持有终端的界面能在退出前恢复终端;返回卸载函数 |
|
||||
| `FAIL_LOUD_RELEASE_TIMEOUT_MS` | `installFailLoud` 等待其 `release` 回调的时长;卡住的清理函数只会延迟致命退出,而不会取消它 |
|
||||
| `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 |
|
||||
| `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 |
|
||||
| `loadOptionalPatches(binName, file)` | 解析一份可选的 patch 列表文件(即 profile 的 `cordis.patch.yml`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 |
|
||||
| `loadOverlayPatches(binName, file)` | 解析一份形状相同的必需 patch 列表文件;文件缺失同样抛出异常,因为该文件是调用方指名的 |
|
||||
| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 挂载静态导入的 Include builtin,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项 |
|
||||
| `watchUserPatches(ctx, options)` | 向现有 Cordis HMR 服务注册指名的 patch 文件;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前用户层)以事务方式重新组合完整 patch 列表,并返回异步 disposer |
|
||||
| `watchUserPatches(ctx, options)` | 向现有 Cordis HMR 服务注册指名的 patch 文件;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前用户层)以事务方式重新组合完整 patch 列表,并返回异步清理函数 |
|
||||
| `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile 机制(见 [Profile](#profiles)) |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject |
|
||||
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献该段的文件以及修补过它的层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 |
|
||||
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来自同一文件且经相同补丁层修改的连续行之前都有一条 `# ==` 注释,标明该文件和这些补丁层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 |
|
||||
| `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
|
||||
|
||||
Loader 结算会在导入或生命周期失败时 reject,并携带失败的配置项与阶段;`boot()` 会 dispose 部分构造的上下文,并用 bin 名称包装该失败。结算后遗留的配置项由独立审计处理:`assertEntriesLoaded` 将已启用却没有 fiber 的配置项转换为 rejection 并列出每个未解析插件;`assertEntriesActivated` 会显式等待每个失败的 fiber,把原始错误堆栈写入启动 rejection,并列出每个等待中配置项尚未解析的服务。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。
|
||||
Loader 结算会在导入或生命周期失败时返回拒绝结果,并携带失败的配置项与阶段;`boot()` 会 dispose 部分构造的上下文,并用 bin 名称包装该失败。结算后遗留的配置项由独立审计处理:`assertEntriesLoaded` 将已启用却没有 fiber 的配置项转换为 rejection 并列出每个未解析插件;`assertEntriesActivated` 会显式等待每个失败的 fiber,把原始错误堆栈写入启动 rejection,并列出每个等待中配置项尚未解析的服务。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。
|
||||
|
||||
Loader 并发挂载各个条目,因此当其他环节失败时,某个界面可能已经持有终端:此时不经过整棵树自身的拆卸就退出,会把 raw 模式、bracketed paste 和键盘协议残留在用户的 shell 上,而尚未返回的终端查询响应会在下一个提示符处显示为字面文本。配置树失败会经 `boot()` 结算:它先释放部分构建的上下文(从而执行该界面自身的 shutdown),再抛出带标签的 rejection。对于 `boot()` 看不到的 rejection(插件游离的异步工作在挂载期间或挂载完成后失败),持有终端的 bin 会传入 `release`,在提交退出前释放整棵树;`dsh` 在 `boot()` 的 `prepare` 回调中捕获根上下文,而不是取其返回值,使该回调覆盖整个挂载窗口。release 执行期间处理函数保持注册并加闩:被报告的始终是第一个 rejection,后续 rejection(包括拆卸自身的)会被吞掉,而不会变成未捕获错误、在拆卸中途杀死进程。
|
||||
Loader 并发挂载各个条目,因此当其他环节失败时,某个界面可能已经持有终端:此时不经过整棵树自身的拆卸就退出,会把 raw 模式、bracketed paste 和键盘协议残留在用户的 shell 上,而尚未返回的终端查询响应会在下一个提示符处显示为字面文本。配置树失败会经 `boot()` 结算:它先 dispose 部分构建的上下文(从而执行该界面自身的 shutdown),再抛出带标签的 rejection。对于 `boot()` 看不到的 rejection(插件游离的异步工作在挂载期间或挂载完成后失败),持有终端的 bin 会传入 `release`,在提交退出前 dispose 整棵树;`dsh` 在 `boot()` 的 `prepare` 回调中捕获根上下文,而不是取其返回值,使该回调覆盖整个挂载窗口。release 执行期间,处理函数保持注册并处于锁定状态:被报告的始终是第一个 rejection,后续拒绝(包括拆卸自身产生的拒绝)会被忽略,而不会变成未捕获错误、在拆卸中途杀死进程。
|
||||
|
||||
配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个已交付的原始/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。
|
||||
配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包)通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与宿主会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个随附的原始/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。
|
||||
|
||||
此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。
|
||||
此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。
|
||||
|
||||
## Profile
|
||||
## Profiles
|
||||
|
||||
profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录(Harness home 由 [`resolveDshHome`](../../util/paths/README.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和用户自己的 `cordis.patch.yml`。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则大声失败。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录(安装目录的应用与各组合包依赖的每个包对应一个符号链接),使任意 profile 中的裸插件名都能经 Node 常规的逐级向上查找解析,而 pnpm 从不管理随安装内置的包。`PROFILE_TEMPLATES`(`web`、`headless`)在首次使用时自动初始化;其他名称在 `initProfile` 创建之前都会大声失败(即 `dsh plugin` 路径)。
|
||||
profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录(Harness home 由 [`resolveDshHome`](../../util/paths/README.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和用户自己的 `cordis.patch.yml`。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录(安装目录的应用与各组合包依赖的每个包对应一个符号链接),使任意 profile 中的裸插件名都能经 Node 常规的逐级向上查找解析,而 pnpm 从不管理随安装内置的包。`PROFILE_TEMPLATES`(`web`、`headless`)在首次使用时自动初始化;其他名称在 `initProfile` 创建之前都会明确报错(即 `dsh plugin` 路径)。`loadProfile` 会将与安装自有组合包元组完全一致的列表规范化为随发行版交付的模板,同时保留 manifest 中其他所有字段;一旦条目有任何额外、缺失或重排,该列表就归用户所有并保持不变。
|
||||
|
||||
用户级的机器本地偏好同样位于 Harness home 中:
|
||||
|
||||
- **`.env`**:产品 CLI 的普通环境层;调用目录的文件优先于 Harness home 的文件,两者都低于继承环境。`loadLayeredEnv` 记录每个值的来源,按不区分大小写的方式拒绝 [bootstrap-only 文件变量](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision),并把其余值物化进 `process.env`,供 Loader 表达式和第三方库使用。受管凭据另存于 [`.credentials.yaml`](../../credentials/credentials-local/README.md);留在任一 `.env` 中的凭据仍是低优先级后备值。
|
||||
- **`cordis.patch.yml`**(home 级)与 **`profiles/<name>/cordis.patch.yml`**:用户 patch 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在组合后的树中,则输出一条 stderr 警告。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用该层,请使用 `[]`。
|
||||
|
||||
长期运行的 surface 会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay/标志 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。
|
||||
长期运行的界面会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,监视器仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay/标志 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离观察方的失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -48,11 +48,11 @@ profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录(Harness home 由 [`
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
`boot()` 不会直接使缓存失效;消费方调用 `addHarnessSourceSection` 时,会在系统提示词靠前位置、逐请求内容之前添加一行短文本,因此不会使跨轮次缓存失效。请求前缀的其他任何变化均由相应的具名消费方持有。
|
||||
`boot()` 不会直接使缓存失效;消费方调用 `addHarnessSourceSection` 时,会在系统提示词靠前位置、逐请求内容之前添加一行短文本,因此不会使跨轮次缓存失效。请求前缀的其他任何变化均由相应的具名消费方负责。
|
||||
|
||||
## 已知限制与延期工作
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生 helper;没有该 helper 的进程内调用方必须使用可解析的相对/file specifier,或提供自己的模块解析钩子。
|
||||
- **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生辅助组件;没有该辅助组件的进程内调用方必须使用可解析的相对/file specifier,或提供自己的模块解析钩子。
|
||||
- **快照回放替换仅识别特定 basename**:只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。
|
||||
- **环境发现以启动为界**:`loadLayeredEnv` 只读取一次调用目录与 Harness home 中的 `.env`;它不搜索父目录,也不跟随之后选择的 workspace。`loadEnv` 仍是非产品 bin 使用的单目录 helper。
|
||||
- **用户 patch 层采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此 profile 覆盖必须重述需要保留的组合包字段。
|
||||
@@ -165,7 +165,7 @@ function readEnvLayer(
|
||||
* Load the product CLI's inherited > invoking-directory `.env` > Harness-home
|
||||
* `.env` snapshot. The Harness home resolves before either file; both files
|
||||
* are checked before either is applied, and accepted values are materialized
|
||||
* without replacing inherited ones. The snapshot preserves source provenance.
|
||||
* without replacing inherited ones. The snapshot preserves which layer supplied each value.
|
||||
* @param binName - the diagnostic prefix on the diagnostics.
|
||||
* @param cwd - the invoking directory whose `.env` is the project layer.
|
||||
* @param warn - sink for the one-line misconfiguration diagnostics.
|
||||
@@ -335,9 +335,9 @@ function parsePatchList(
|
||||
return parsed as PatchOptions[]
|
||||
}
|
||||
|
||||
/** One overlay patch list with the label provenance comments print for it. */
|
||||
/** One overlay patch list with the source label printed in dump comments. */
|
||||
export interface ConfigDumpLayer {
|
||||
/** Source name shown in provenance comments (a file basename or path). */
|
||||
/** Source name shown in dump comments (a file basename or path). */
|
||||
label: string
|
||||
/** The layer's patches, from {@link loadOverlayPatches} / {@link loadOptionalPatches}. */
|
||||
patches: PatchOptions[]
|
||||
@@ -353,10 +353,10 @@ export interface ConfigDumpLayer {
|
||||
* sees) compose identically — then render the result as YAML in the same
|
||||
* dialect (`!!js` expressions print verbatim, unevaluated).
|
||||
*
|
||||
* Every run of rows with the same provenance is preceded by a `# ==` comment
|
||||
* Every run of rows from the same file and patch layers is preceded by a `# ==` comment
|
||||
* naming the file that contributed the rows and any layers that patched them,
|
||||
* so the output stays a loadable YAML document while showing which section
|
||||
* comes from which file. Provenance is derived from single-call prefix
|
||||
* comes from which file. The file and patch labels are derived from single-call prefix
|
||||
* snapshots (base + layers 1..k), diffed positionally: the patch algorithm
|
||||
* only rewrites rows in place or appends, so a top-level index identifies one
|
||||
* row across snapshots, and a layer whose addition changes the row (config
|
||||
@@ -372,7 +372,7 @@ export interface ConfigDumpLayer {
|
||||
* @param layers - overlay layers in application order (later wins).
|
||||
* @param warn - sink for skipped-patch diagnostics; defaults to stderr.
|
||||
* @returns the composed entry list rendered as a YAML document with
|
||||
* provenance comment separators.
|
||||
* source comment separators.
|
||||
*/
|
||||
export function renderConfigDump(
|
||||
binName: string,
|
||||
@@ -439,7 +439,7 @@ export function renderConfigDump(
|
||||
return groupedDump(composed, provenance)
|
||||
}
|
||||
|
||||
/** Render the composed rows grouped under one provenance comment per contiguous run. */
|
||||
/** Render the composed rows grouped under one source-and-patches comment per contiguous run. */
|
||||
function groupedDump(
|
||||
composed: readonly unknown[],
|
||||
provenance: readonly { origin: string; patchedBy: string[] }[],
|
||||
@@ -455,7 +455,7 @@ function groupedDump(
|
||||
}
|
||||
for (let index = 0; index < composed.length; index += 1) {
|
||||
const record = provenance[index]
|
||||
/* v8 ignore next -- provenance is index-aligned with composed by construction */
|
||||
/* v8 ignore next -- this array is index-aligned with composed by construction */
|
||||
if (record === undefined) continue
|
||||
const label = record.patchedBy.length === 0
|
||||
? record.origin
|
||||
@@ -641,7 +641,7 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
|
||||
|
||||
/**
|
||||
* Value mirrors used because Cordis's const enum has no runtime object to import.
|
||||
* Keep aligned with `packages/cordis/tool-cordis/src/fiber-state.ts` and
|
||||
* Keep aligned with `packages/self-modification/tool-cordis/src/fiber-state.ts` and
|
||||
* `packages/client/web/src/loader-status.ts`.
|
||||
*/
|
||||
const FIBER_PENDING = 0 as FiberState.PENDING
|
||||
@@ -114,6 +114,11 @@ export function resolveProfileDir(name: string, home: string = resolveDshHome())
|
||||
/** The shipped profile templates auto-initialized on first use, by name. */
|
||||
export const PROFILE_TEMPLATES: Record<string, readonly string[]> = {
|
||||
web: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],
|
||||
headless: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'],
|
||||
}
|
||||
|
||||
/** Installation-owned bundle tuples normalized to the shipped template. */
|
||||
const INSTALLATION_OWNED_PROFILE_TUPLES: Record<string, readonly string[]> = {
|
||||
headless: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless'],
|
||||
}
|
||||
|
||||
@@ -203,10 +208,10 @@ function ensureSymlink(link: string, target: string): void {
|
||||
* directory after the profile's own `node_modules`, so every in-box plugin
|
||||
* resolves without pnpm ever managing it — the exact "bundles come from the
|
||||
* installation" contract. The closure (not just direct dependencies) is
|
||||
* required for out-of-tree plugins: their peer dependencies name seam
|
||||
* packages (`dsh-compact`, `dsh-invariants`, ...) that the app reaches only
|
||||
* through its implementation packages. Symlinked packages resolve their own
|
||||
* dependencies from their real directories (Node's default
|
||||
* required for out-of-tree plugins: their peer dependencies name Service
|
||||
* Definition packages (`dsh-compact`, `dsh-invariants`, ...) that the app
|
||||
* reaches only through its Service provider packages. Symlinked packages
|
||||
* resolve their own dependencies from their real directories (Node's default
|
||||
* symlink-following), so each package needs only its one flat link.
|
||||
* Idempotent: correct links are kept and moved installations are
|
||||
* re-pointed; a stale link to a vanished package stays until its name is
|
||||
@@ -226,7 +231,7 @@ export function healProfilesModuleFallback(installAnchor: string, home: string =
|
||||
// map itself (first resolution wins, matching Node's own nearest-wins).
|
||||
const queue: { anchor: string; manifest: ProfileManifest }[] = [{ anchor: installAnchor, manifest: appManifest }]
|
||||
for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
|
||||
// Peer dependencies participate: seam packages (dsh-subprocess,
|
||||
// Peer dependencies participate: Service Definition packages (dsh-subprocess,
|
||||
// dsh-compact, ...) are peers of their implementations, never plain
|
||||
// dependencies, yet out-of-tree plugins import them directly.
|
||||
/* v8 ignore next -- a real app manifest always declares dependencies */
|
||||
@@ -279,6 +284,32 @@ export function writeProfileManifest(dir: string, manifest: ProfileManifest): vo
|
||||
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest, undefined, 2) + '\n')
|
||||
}
|
||||
|
||||
/** Return whether two bundle lists have the same values in the same order. */
|
||||
function sameBundles(left: readonly string[], right: readonly string[]): boolean {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an exact installation-owned bundle tuple to its shipped template
|
||||
* while preserving every other manifest field. Any other list is user-owned.
|
||||
*/
|
||||
function normalizeShippedProfile(name: string, dir: string, manifest: ProfileManifest): ProfileManifest {
|
||||
const installationOwned = INSTALLATION_OWNED_PROFILE_TUPLES[name]
|
||||
const current = PROFILE_TEMPLATES[name]
|
||||
const bundles = manifest.dsh?.profile?.bundles
|
||||
if (installationOwned === undefined || current === undefined || bundles === undefined
|
||||
|| !sameBundles(bundles, installationOwned)) return manifest
|
||||
const normalized: ProfileManifest = {
|
||||
...manifest,
|
||||
dsh: {
|
||||
...manifest.dsh,
|
||||
profile: { ...manifest.dsh?.profile, bundles: [...current] },
|
||||
},
|
||||
}
|
||||
writeProfileManifest(dir, normalized)
|
||||
return normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a package's root directory from one anchor without depending on the
|
||||
* package exporting `./package.json` (`require.resolve` would need that):
|
||||
@@ -350,7 +381,7 @@ export function loadProfile(
|
||||
}
|
||||
initProfile(dir, template)
|
||||
}
|
||||
const manifest = readProfileManifest(binName, dir)
|
||||
const manifest = normalizeShippedProfile(name, dir, readProfileManifest(binName, dir))
|
||||
// A hand-written profile manifest may omit the dsh section entirely.
|
||||
const bundles = manifest.dsh?.profile?.bundles ?? []
|
||||
const layers = bundles.map((packageName): ProfileLayer => {
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* `renderConfigDump` behavior: the offline composition must equal what
|
||||
* `boot()` mounts (same parser, same patch algorithm), print `!!js`
|
||||
* expressions verbatim, separate provenance runs with comment lines while
|
||||
* expressions verbatim, separate source-file runs with comment lines while
|
||||
* staying one loadable YAML document, and report skipped patches through
|
||||
* `warn` instead of failing — mirroring the Loader's boot-time warning for a
|
||||
* shared overlay whose row exists only on another surface.
|
||||
@@ -35,7 +35,7 @@ function writeBase(dir: string): string {
|
||||
}
|
||||
|
||||
describe('renderConfigDump', () => {
|
||||
it('composes overlay layers in order, prints !!js verbatim, and labels each section with its provenance', () => {
|
||||
it('composes overlay layers in order, prints !!js verbatim, and labels each section with its source and patches', () => {
|
||||
const dir = tmp()
|
||||
const base = writeBase(dir)
|
||||
const surface = join(dir, 'surface.yml')
|
||||
@@ -78,7 +78,7 @@ describe('renderConfigDump', () => {
|
||||
])
|
||||
// Unevaluated: the expression text round-trips as a !!js scalar.
|
||||
expect(dump).toContain('!!js process.env.DSH_DUMP_SPEC')
|
||||
// Provenance separators: origin file, plus every layer that changed the
|
||||
// Source separators: origin file, plus every layer that changed the
|
||||
// row; an inserted row carries the inserting layer as its origin.
|
||||
expect(dump).toContain('# == base.yml, patched by surface.yml')
|
||||
expect(dump).toContain('# == base.yml\n- id: untouched')
|
||||
@@ -86,7 +86,7 @@ describe('renderConfigDump', () => {
|
||||
expect(dump.indexOf('# == base.yml, patched by surface.yml')).toBeLessThan(dump.indexOf('# == base.yml\n- id: untouched'))
|
||||
})
|
||||
|
||||
it('groups contiguous same-provenance rows under one separator', () => {
|
||||
it('groups contiguous rows with the same origin and patches under one separator', () => {
|
||||
const dir = tmp()
|
||||
const base = join(dir, 'base.yml')
|
||||
writeFileSync(base, [
|
||||
@@ -130,7 +130,7 @@ describe('renderConfigDump', () => {
|
||||
config?: { config?: { v?: number } }[]
|
||||
}[]
|
||||
expect(parsed[0]?.config?.[0]?.config?.v).toBe(1)
|
||||
// The skipped layer did not change the row, so it is not in provenance.
|
||||
// The skipped layer did not change the row, so the comment does not list it.
|
||||
expect(dump).toContain('# == base.yml, patched by a.yml\n- id: g')
|
||||
expect(dump).not.toContain('b.yml\n- id: g')
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
@@ -6,14 +7,19 @@ import { Context } from 'cordis'
|
||||
import Hmr from '@cordisjs/plugin-hmr'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Timer from '@cordisjs/plugin-timer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
async function bootHmr(dir: string): Promise<Context> {
|
||||
async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(dir).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
await ctx.plugin(Timer)
|
||||
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
|
||||
await ctx.plugin(Hmr, {
|
||||
root,
|
||||
ignored: [],
|
||||
debounce: 0,
|
||||
...usePolling === undefined ? {} : { usePolling },
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -26,6 +32,57 @@ async function eventually(test: () => boolean, message: string): Promise<void> {
|
||||
}
|
||||
|
||||
describe('HMR exact config paths', () => {
|
||||
it('observes module changes when its watch base is a filesystem alias', { timeout: 30_000 }, async () => {
|
||||
const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-module-canonical-'))
|
||||
const alias = `${target}-alias`
|
||||
const aliasFilename = join(alias, 'module.ts')
|
||||
symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
writeFileSync(aliasFilename, 'export const generation = 0\n')
|
||||
// This acceptance owns alias-to-cache identity. Other cases below exercise
|
||||
// native events; polling keeps Windows fs.watch queue pressure out of it.
|
||||
const ctx = await bootHmr(alias, ['.'], true)
|
||||
const filename = join(await realpath(target), 'module.ts')
|
||||
const expected = pathToFileURL(filename).href
|
||||
const cacheHas = vi.spyOn(ctx.loader.internal!.loadCache, 'has').mockReturnValue(false)
|
||||
const observed: string[] = []
|
||||
ctx.on('hmr/change', (url) => { observed.push(url) })
|
||||
try {
|
||||
const deadline = Date.now() + 20_000
|
||||
for (let generation = 1; !observed.includes(expected); generation += 1) {
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`HMR did not observe ${expected} through the alias; observed ${JSON.stringify(observed)}`)
|
||||
}
|
||||
// The watch base, not the writer spelling, is the alias under test.
|
||||
// Grow the file on every write: polling must not depend on timestamp
|
||||
// precision when several generations land inside one filesystem tick.
|
||||
writeFileSync(filename, `export const generation = ${generation}\n${' '.repeat(generation)}\n`)
|
||||
// Leave Chokidar's atomic-write window idle so one coalesced change can publish.
|
||||
await new Promise(resolve => setTimeout(resolve, 250))
|
||||
}
|
||||
expect(cacheHas).toHaveBeenCalledWith(expected)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
rmSync(alias, { force: true })
|
||||
rmSync(target, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('collapses filesystem aliases before registering an exact watch', async () => {
|
||||
const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-canonical-'))
|
||||
const alias = `${target}-alias`
|
||||
symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
const ctx = await bootHmr(alias)
|
||||
try {
|
||||
await ctx.hmr.registerConfig('plugins.yml', () => {})
|
||||
await expect(ctx.hmr.registerConfig(join(await realpath(target), 'plugins.yml'), () => {}))
|
||||
.rejects.toThrow('config path already registered')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
rmSync(alias, { force: true })
|
||||
rmSync(target, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
|
||||
const filename = join(dir, 'plugins.yml')
|
||||
@@ -133,6 +190,9 @@ describe('HMR exact config paths', () => {
|
||||
expect(observed.error).toBeInstanceOf(Error)
|
||||
expect(observed.error.message).toBe('42')
|
||||
|
||||
// Let Chokidar's atomic-write window close before requiring a distinct
|
||||
// second notification from the same path.
|
||||
await new Promise(resolve => setTimeout(resolve, 250))
|
||||
writeFileSync(filename, 'invalid again')
|
||||
await eventually(() => failureCount === 2, 'HMR stopped broadcasting after an observer rejected')
|
||||
} finally {
|
||||
@@ -161,6 +161,33 @@ describe('loadProfile', () => {
|
||||
.toEqual([...PROFILE_TEMPLATES.web ?? []])
|
||||
})
|
||||
|
||||
it('normalizes only the exact installation-owned headless bundle tuple', () => {
|
||||
const anchor = stageInstallation({
|
||||
'@deepseek-ai/dsh-base': { patch: '[]\n' },
|
||||
'@deepseek-ai/dsh-web-app': { patch: '[]\n' },
|
||||
'@deepseek-ai/dsh-headless': { patch: '[]\n' },
|
||||
'custom-bundle': { patch: '[]\n' },
|
||||
})
|
||||
const home = tmp()
|
||||
const stock = resolveProfileDir('headless', home)
|
||||
initProfile(stock, [
|
||||
'@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless',
|
||||
])
|
||||
loadProfile('t', 'headless', anchor, home)
|
||||
expect(readProfileManifest('t', stock).dsh?.profile?.bundles)
|
||||
.toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'])
|
||||
|
||||
const customHome = tmp()
|
||||
const custom = resolveProfileDir('headless', customHome)
|
||||
initProfile(custom, [
|
||||
'@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless', 'custom-bundle',
|
||||
])
|
||||
loadProfile('t', 'headless', anchor, customHome)
|
||||
expect(readProfileManifest('t', custom).dsh?.profile?.bundles).toEqual([
|
||||
'@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless', 'custom-bundle',
|
||||
])
|
||||
})
|
||||
|
||||
it('fails loud when a listed bundle declares no dsh.bundle', () => {
|
||||
const anchor = stageInstallation({ 'not-a-bundle': {} })
|
||||
const home = tmp()
|
||||
@@ -36,14 +36,14 @@ describe('RepositoryCache', () => {
|
||||
calls.push(directory)
|
||||
await fakePackage(directory)
|
||||
}
|
||||
const cache = new RepositoryCache(root, install)
|
||||
const cache = new RepositoryCache(root, { install })
|
||||
const specifier = 'github:owner/repository#0123456789abcdef'
|
||||
|
||||
const [first, concurrent] = await Promise.all([cache.resolve(specifier), cache.resolve(specifier)])
|
||||
expect(concurrent).toBe(first)
|
||||
expect(calls).toHaveLength(1)
|
||||
|
||||
const reopened = new RepositoryCache(root, async () => { throw new Error('cache miss') })
|
||||
const reopened = new RepositoryCache(root, { install: async () => { throw new Error('cache miss') } })
|
||||
expect(await reopened.resolve(specifier)).toBe(first)
|
||||
expect(JSON.parse(await readFile(join(first, '..', '..', 'package.json'), 'utf8'))).toMatchObject({
|
||||
packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`,
|
||||
@@ -68,8 +68,8 @@ describe('RepositoryCache', () => {
|
||||
const specifier = 'github:owner/repository#race'
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
new RepositoryCache(root, install).resolve(specifier),
|
||||
new RepositoryCache(root, install).resolve(specifier),
|
||||
new RepositoryCache(root, { install }).resolve(specifier),
|
||||
new RepositoryCache(root, { install }).resolve(specifier),
|
||||
])
|
||||
|
||||
expect(second).toBe(first)
|
||||
@@ -80,11 +80,11 @@ describe('RepositoryCache', () => {
|
||||
it('removes a failed staging tree and permits an exact retry', async () => {
|
||||
const root = await temporaryRoot('repository-retry')
|
||||
let attempts = 0
|
||||
const cache = new RepositoryCache(root, async (directory) => {
|
||||
const cache = new RepositoryCache(root, { install: async (directory) => {
|
||||
attempts += 1
|
||||
if (attempts === 1) throw new Error('install failed')
|
||||
await fakePackage(directory)
|
||||
})
|
||||
} })
|
||||
|
||||
await expect(cache.resolve('github:owner/repository#ref')).rejects.toThrow('failed to prepare repository')
|
||||
expect(await readdir(root)).toEqual([])
|
||||
@@ -94,7 +94,7 @@ describe('RepositoryCache', () => {
|
||||
|
||||
it('rejects empty or padded specifiers before touching the cache', async () => {
|
||||
const root = await temporaryRoot('repository-input')
|
||||
const cache = new RepositoryCache(root, fakePackage)
|
||||
const cache = new RepositoryCache(root, { install: fakePackage })
|
||||
expect(() => cache.resolve('')).toThrow('non-empty unpadded string')
|
||||
expect(() => cache.resolve(' github:owner/repository#ref')).toThrow('non-empty unpadded string')
|
||||
await expect(readdir(root)).resolves.toEqual([])
|
||||
@@ -107,35 +107,69 @@ describe('RepositoryCache', () => {
|
||||
const entry = join(root, key)
|
||||
await mkdir(join(entry, 'node_modules', 'repository'), { recursive: true })
|
||||
await writeFile(join(entry, '.repository-cache.json'), '{}\n')
|
||||
const cache = new RepositoryCache(root, async () => { throw new Error('must not reinstall') })
|
||||
const cache = new RepositoryCache(root, { install: async () => { throw new Error('must not reinstall') } })
|
||||
|
||||
await expect(cache.resolve(specifier)).rejects.toThrow('repository cache marker is invalid')
|
||||
})
|
||||
|
||||
it('selects and prepares a root .dsh-plugin Git subpath through the bundled pnpm', { timeout: 60_000 }, async () => {
|
||||
it('isolates and prepares a .dsh-plugin Git subpath from an enclosing pnpm workspace', { timeout: 60_000 }, async () => {
|
||||
const root = await temporaryRoot('repository-pnpm')
|
||||
const repository = join(root, 'source')
|
||||
await mkdir(join(repository, '.dsh-plugin'), { recursive: true })
|
||||
await mkdir(join(repository, 'build-helper'), { recursive: true })
|
||||
await mkdir(join(repository, 'prepare-helper'), { recursive: true })
|
||||
await mkdir(join(repository, 'skills', 'fixture'), { recursive: true })
|
||||
await writeFile(join(repository, 'package.json'), `${JSON.stringify({
|
||||
name: 'repository-fixture',
|
||||
private: true,
|
||||
version: '1.0.0',
|
||||
packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`,
|
||||
})}\n`)
|
||||
await writeFile(join(repository, 'pnpm-workspace.yaml'), 'packages: []\n')
|
||||
await writeFile(join(repository, 'pnpm-lock.yaml'), [
|
||||
"lockfileVersion: '9.0'",
|
||||
'settings:',
|
||||
' autoInstallPeers: true',
|
||||
' excludeLinksFromLockfile: false',
|
||||
'importers:',
|
||||
' .: {}',
|
||||
'',
|
||||
].join('\n'))
|
||||
await writeFile(join(repository, 'build-helper', 'package.json'), `${JSON.stringify({
|
||||
name: 'repository-build-helper',
|
||||
version: '1.0.0',
|
||||
bin: 'index.js',
|
||||
})}\n`)
|
||||
await writeFile(join(repository, 'build-helper', 'index.js'), [
|
||||
'#!/usr/bin/env node',
|
||||
"require('node:fs').writeFileSync('dependency-built.txt', 'dependency available\\n')",
|
||||
'',
|
||||
].join('\n'), { mode: 0o700 })
|
||||
await writeFile(join(repository, 'prepare-helper', 'package.json'), `${JSON.stringify({
|
||||
name: 'repository-prepare-helper',
|
||||
version: '1.0.0',
|
||||
bin: { 'dsh-plugin-prepare': 'index.js' },
|
||||
})}\n`)
|
||||
await writeFile(join(repository, 'prepare-helper', 'index.js'), [
|
||||
'#!/usr/bin/env node',
|
||||
"const { cpSync, mkdirSync, writeFileSync } = require('node:fs')",
|
||||
"mkdirSync('dsh-plugin-assets/skills', { recursive: true })",
|
||||
"cpSync('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })",
|
||||
"writeFileSync('dsh-plugin.mjs', 'export function apply() {}\\n')",
|
||||
"writeFileSync('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)",
|
||||
'',
|
||||
].join('\n'), { mode: 0o700 })
|
||||
await writeFile(join(repository, 'skills', 'fixture', 'SKILL.md'), 'repository skill source\n')
|
||||
await writeFile(join(repository, '.dsh-plugin', 'package.json'), `${JSON.stringify({
|
||||
name: 'repository-plugin-fixture',
|
||||
version: '1.0.0',
|
||||
scripts: { prepare: 'node prepare.mjs' },
|
||||
scripts: { prepack: 'repository-build-helper && dsh-plugin-prepare' },
|
||||
devDependencies: {
|
||||
'repository-build-helper': 'file:../build-helper',
|
||||
'repository-prepare-helper': 'file:../prepare-helper',
|
||||
},
|
||||
dsh: { skills: ['../skills'] },
|
||||
})}\n`)
|
||||
await writeFile(join(repository, '.dsh-plugin', 'prepare.mjs'), [
|
||||
"import { cp, mkdir, writeFile } from 'node:fs/promises'",
|
||||
"await mkdir('dsh-plugin-assets/skills', { recursive: true })",
|
||||
"await cp('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })",
|
||||
"await writeFile('dsh-plugin.mjs', 'export function apply() {}\\n')",
|
||||
"await writeFile('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)",
|
||||
'',
|
||||
].join('\n'))
|
||||
await execFileAsync('git', ['init', '--quiet'], { cwd: repository })
|
||||
await execFileAsync('git', ['add', '.'], { cwd: repository })
|
||||
await execFileAsync('git', [
|
||||
@@ -149,6 +183,7 @@ describe('RepositoryCache', () => {
|
||||
vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden')
|
||||
|
||||
const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier)
|
||||
await expect(readFile(join(installed, 'dependency-built.txt'), 'utf8')).resolves.toBe('dependency available\n')
|
||||
await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent\n')
|
||||
await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply')
|
||||
await expect(readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8'))
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bundle/README.md
|
||||
README.md: 4759170435a80e85731446cef21d24fff2abed66
|
||||
README.zh.md: 1ef610a1b7b3c591c9a900e04f2d8096b0b086b9
|
||||
README.md: 696aa9ef7bbed23774f2b9ab2648ca83edcf0978
|
||||
README.zh.md: 2bb22c7949d759f404288548ea0eccfa0aac866b
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Profile bundles: npm packages whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`, making them installable patch layers for `dsh --profile` compositions ([profile contract](../ui/app-boot/README.md#profiles)). A bundle's substance is its patch list; some also ship runtime glue plugins their patch mounts.
|
||||
Profile bundles: npm packages whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`, making them installable patch layers for `dsh --profile` compositions ([profile contract](../boot/app-boot/README.md#profiles)). A bundle's substance is its patch list; some also ship runtime glue plugins their patch mounts.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`base/`](base/README.md) | The shared dsh core every profile applies first | — (patch only) |
|
||||
| [`web-app/`](web-app/README.md) | Browser surface: web patch layer + runtime glue plugin | mounts rows |
|
||||
| [`headless/`](headless/README.md) | One-shot task mode over base + web-app | mounts `headless-runner` |
|
||||
| [`headless/`](headless/README.md) | Direct one-shot task mode over base, with no Host or Web layer | mounts `headless-runner` |
|
||||
|
||||
In-box bundles resolve from the dsh installation; out-of-tree bundles install into a profile through `dsh plugin --profile <name> add <package>`.
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Profile 组合包:在 manifest(元数据清单)中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包,因此可作为 patch 层安装进 `dsh --profile` 组合([profile 契约](../ui/app-boot/README.md#profiles))。组合包的实体是它的 patch 列表;有些组合包还附带由其 patch 挂载的运行时粘合插件。
|
||||
Profile 组合包:在 manifest(元数据清单)中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包,因此可作为 patch 层安装进 `dsh --profile` 组合([profile 约定](../boot/app-boot/README.md#profiles))。组合包的实体是它的 patch 列表;有些组合包还附带由其 patch 挂载的运行时粘合插件。
|
||||
|
||||
| 包 | 职责 | ctx key |
|
||||
|---|---|---|
|
||||
| [`base/`](base/README.md) | 每个 profile 最先应用的共享 dsh 核心 | —(仅 patch) |
|
||||
| [`web-app/`](web-app/README.md) | 浏览器表层:web patch 层 + 运行时粘合插件 | 挂载多条配置行 |
|
||||
| [`headless/`](headless/README.md) | 叠加在 base + web-app 之上的一次性任务模式 | 挂载 `headless-runner` |
|
||||
| [`headless/`](headless/README.md) | 直接运行在 base 之上的一次性任务模式,不含 Host 或 Web 层 | 挂载 `headless-runner` |
|
||||
|
||||
内置组合包从 dsh 安装目录解析;树外(out-of-tree)组合包通过 `dsh plugin --profile <name> add <package>` 安装进 profile。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bundle/base/README.md
|
||||
README.md: 301d397d4c87687b382665cf63af47ab5e3f85be
|
||||
README.zh.md: f007bc817b6cbad84725fe8abe72549cf67d8cd7
|
||||
README.md: fb003908a262dc21edd3c9d49c972e487534f367
|
||||
README.zh.md: 13e64db6d34374fac63bf9bfd60544fc46b86f35
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code.
|
||||
The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code.
|
||||
|
||||
The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。
|
||||
以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.md) 选择、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。
|
||||
|
||||
行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。
|
||||
|
||||
|
||||
@@ -65,6 +65,14 @@
|
||||
- id: agent
|
||||
name: '@deepseek-ai/dsh-agent'
|
||||
|
||||
# The transport-independent default for Agents created by front doors.
|
||||
# Settings may supply a saved selection; consumers read it at creation time.
|
||||
- id: agent-default-model
|
||||
name: '@deepseek-ai/dsh-agent-default-model'
|
||||
config:
|
||||
provider: deepseek-official
|
||||
model: deepseek-v4-flash
|
||||
|
||||
- id: tasks
|
||||
name: '@deepseek-ai/dsh-tasks-local'
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@cordisjs/plugin-hmr": "workspace:*",
|
||||
"@cordisjs/plugin-timer": "workspace:*",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-default-model": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-env": "workspace:^",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bundle/headless/README.md
|
||||
README.md: 661b377817482d22f58f22b573075722646729a2
|
||||
README.zh.md: a6b91a8e60fdcc06ba23e07dcb2f4208ea1020f7
|
||||
README.md: f8b76b77f2beb22f501a49f0fc4cf5cd72223765
|
||||
README.zh.md: aae8ab5bea663b8909de942f72615f5ef9b16c84
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md): it moves the webserver to an OS-assigned port (parallel runs never collide), silences the URL line, and inserts this package's `headless-runner` plugin (config `{task}`). The runner drives one task turn through the in-process API carrier (`InProcessApiClient` over `toFetchHandler(ctx.apiProxy)`, so the full wire chain — serialization, zod, SSE framing — really runs), waits at idle until that mux has consumed the session's final event sequence, aggregates the turn's final assistant text, writes it to stdout, and requests exit (completed → 0, else 1) through the launcher-provided `ctx.headlessIo` seam. The Web composition stays mounted, so the running session is observable in a browser at the stderr-announced URL. The launcher patches the task text in (`dsh run "task"`), and fails loud when the selected profile lacks this row.
|
||||
The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`). It mounts no Host, HTTP server, Web runtime, or browser plugin.
|
||||
|
||||
After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` host hook (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The launcher patches the task text in (`dsh run "task"`) and fails loud when the selected profile lacks this row.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the runner submits the task as an ordinary user message over the shared composition; prompts and tools belong to the base/web bundles.
|
||||
None, as the runner submits the task as an ordinary user message; prompts and tools belong to the base and headless bundle rows.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -14,5 +16,5 @@ None; the runner adds nothing to the request prefix.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **One turn only** — the runner anchors on the first message-triggered turn and exits at its end; queued follow-ups and multi-turn tasks are out of scope.
|
||||
- **`ctx.headlessIo` is launcher-owned** — booting the headless profile outside the `dsh` launcher fails loud at activation until the host provides the seam.
|
||||
- **One submitted task only** — the runner has no interactive follow-up surface; it waits through any work the Agent completes before returning to idle and prints the last non-empty assistant message in that interval.
|
||||
- **`ctx.headlessIo` is launcher-owned** — booting the headless profile outside the `dsh` launcher fails loud at activation until the host provides the hook.
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md) 之上:把 webserver 移到 OS 分配的端口(并行运行绝不冲突),关闭 URL 行输出,并插入本包的 `headless-runner` 插件(配置为 `{task}`)。runner 通过进程内 API 载体(架在 `toFetchHandler(ctx.apiProxy)` 之上的 `InProcessApiClient`,因此序列化、zod、SSE(Server-Sent Events)帧封装这整条 wire 链路都会真实运行)驱动一个任务轮次,在 idle 时等待该 mux 消费完会话的最终事件序号,再聚合该轮次最终的 assistant 文本,写到 stdout,并经启动器提供的 `ctx.headlessIo` seam 请求退出(完成 → 0,否则 1)。Web 组合保持挂载,因此运行中的会话可在浏览器中通过 stderr 公告的 URL 观察。启动器把任务文本 patch 进来(`dsh run "task"`);若所选 profile 缺少该行,则显式报错。
|
||||
dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。
|
||||
|
||||
Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.headlessIo` 宿主钩子请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。启动器把任务文本 patch 进来(`dsh run "task"`);若所选 profile 缺少该行,则显式报错。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。runner 把任务作为普通用户消息经共享组合提交;提示词与工具归 base/web 组合包所有。
|
||||
无影响,因为 runner 把任务作为普通用户消息提交;提示词与工具归 base 和 headless 组合包中的相应行所有。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -14,5 +16,5 @@ dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **只运行一个轮次**:runner 锚定第一个由消息触发的轮次,并在其结束时退出;排队的后续消息与多轮任务不在范围内。
|
||||
- **`ctx.headlessIo` 由启动器持有**:在 `dsh` 启动器之外启动 headless profile 会在激活时大声失败,直到宿主提供该 seam。
|
||||
- **只提交一个任务**:runner 没有用于交互式后续输入的 surface;它会等待 Agent 在返回 idle 前完成的所有工作,并打印该区间内最后一条非空 assistant 消息。
|
||||
- **`ctx.headlessIo` 由启动器持有**:在 `dsh` 启动器之外启动 headless profile 会在激活时明确报错,直到宿主提供该钩子。
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
# The dsh-headless bundle patch: one-shot task mode over dsh-base +
|
||||
# dsh-web-app. The web composition stays mounted (the session is observable
|
||||
# in a browser while it runs); this layer silences the URL line and the
|
||||
# GUI-orientation surface context (this user is not in the GUI), moves the
|
||||
# webserver to an OS-assigned port so parallel headless runs never collide,
|
||||
# and mounts the one-shot runner. The launcher patches the runner's `task`.
|
||||
# The dsh-headless bundle patch: one-shot task mode directly over dsh-base.
|
||||
# It mounts no Host, HTTP server, Web runtime, or browser plugin. The launcher
|
||||
# patches the runner's `task`; the direct driver creates an Agent through the
|
||||
# core registry and prints the final durable assistant message.
|
||||
|
||||
- id: webserver
|
||||
- id: system-prompt
|
||||
config:
|
||||
host: 127.0.0.1
|
||||
port: 0
|
||||
persona: >-
|
||||
You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
|
||||
|
||||
- id: web-runtime
|
||||
# One-shot runs never watch or reload their composition.
|
||||
- id: hmr
|
||||
disabled: true
|
||||
|
||||
- id: tools
|
||||
config:
|
||||
mode: production
|
||||
printUrl: false
|
||||
surfaceContext: false
|
||||
# Keep the same temporary process-wide Code Mode opt-in as the Web surface.
|
||||
mode: !!js process.env.DSH_TOOLS_MODE
|
||||
|
||||
- insert:
|
||||
# Code Mode is a core execution capability, not a Web component.
|
||||
- id: code-runtime
|
||||
name: '@deepseek-ai/dsh-code-runtime-worker'
|
||||
|
||||
- id: headless-runner
|
||||
name: '@deepseek-ai/dsh-headless'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-headless",
|
||||
"description": "The dsh one-shot bundle: a patch layer over dsh-base + dsh-web-app plus the runner plugin driving one task turn through the in-process API carrier",
|
||||
"description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -32,22 +32,23 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-code-runtime-worker": "workspace:^",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-default-model": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-default-model": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
|
||||
@@ -1,36 +1,33 @@
|
||||
/**
|
||||
* @deepseek-ai/dsh-headless — the one-shot headless bundle: the bundle patch
|
||||
* (`cordis.patch.yml`) rides over dsh-base + dsh-web-app (the headless
|
||||
* session is web-observable while it runs — same composition), and this
|
||||
* runner plugin drives one task through the in-process API carrier
|
||||
* (InProcessApiClient over toFetchHandler(ctx.apiProxy), so the full wire
|
||||
* chain — serialization, zod, SSE framing — really runs), prints the final
|
||||
* assistant text at agent quiescence, and exits (completed → 0, else 1). The
|
||||
* task text arrives as launcher-patched config (`dsh run "task"`).
|
||||
* @deepseek-ai/dsh-headless — one-shot direct Agent driver. The bundle patch
|
||||
* rides over dsh-base without Host, HTTP, or browser plugins; this runner
|
||||
* creates one Agent through the core registry, drives the task to quiescence,
|
||||
* flushes its Session, prints the final assistant text, and exits.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-headless
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
// Empty type imports carry the httpServer and agent/status Context merges used below.
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
||||
import type { ModelSelectionRef } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
// Empty type import carries the loader Context merge for the settlement await.
|
||||
import type {} from '@cordisjs/plugin-loader'
|
||||
import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'headless-runner'
|
||||
|
||||
/** Services required before the one-shot turn can start. */
|
||||
export const inject = ['apiProxy', 'httpServer']
|
||||
/** Core services required before the one-shot turn can start. */
|
||||
export const inject = ['agentDefaultModel', 'agents', 'sessions']
|
||||
|
||||
/** Plugin config: the task, patched in by the launcher. */
|
||||
export interface Config {
|
||||
/** The prompt text for the single turn. */
|
||||
/** The prompt text for the single run. */
|
||||
task: string
|
||||
}
|
||||
|
||||
@@ -38,16 +35,15 @@ export const Config: z<Config> = z.object({
|
||||
task: z.string().required(),
|
||||
})
|
||||
|
||||
/** Outcome of one headless run: aggregated final text plus the last turn-end reason kind. */
|
||||
interface TurnOutcome {
|
||||
/** Outcome of one owned run interval. */
|
||||
interface RunOutcome {
|
||||
text: string
|
||||
reason: string
|
||||
reason: SessionEvent<'turn/end'>['data']['reason'] | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The process-facing effects of one run, injectable for tests: output
|
||||
* streams and the exit request (the launcher wires it to its bounded
|
||||
* shutdown controller).
|
||||
* Process-facing effects of one run, injectable for tests. The launcher owns
|
||||
* bounded tree shutdown and wires `exit()` to it.
|
||||
*/
|
||||
export interface HeadlessIo {
|
||||
stdout: { write(chunk: string): unknown }
|
||||
@@ -56,127 +52,94 @@ export interface HeadlessIo {
|
||||
exit(code: number): void
|
||||
}
|
||||
|
||||
/** Host seam: the launcher provides the exit wiring before the tree mounts. */
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Process-facing effects for the one-shot headless runner. */
|
||||
/** Process-facing effects provided before the headless tree mounts. */
|
||||
headlessIo?: HeadlessIo
|
||||
}
|
||||
}
|
||||
|
||||
/** Unwrap an RpcResponse or fail loud: business errors print and exit 1. */
|
||||
async function unwrap<T>(response: RpcResponse<T>, io: HeadlessIo): Promise<T> {
|
||||
if (response.result.ok) return response.result.value
|
||||
const { code, message } = response.result.error
|
||||
io.stderr.write(`dsh: ${code}: ${message}\n`)
|
||||
io.exit(1)
|
||||
// Exit is asynchronous (bounded tree disposal); park this turn forever so
|
||||
// no further request rides a session that is already being torn down.
|
||||
return new Promise<never>(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume mux frames until the agent reaches idle, per the one-shot CLI
|
||||
* idle-to-idle contract: the stream opens immediately before the prompt, and
|
||||
* its first observed turn/start begins the task. Text is the last committed
|
||||
* assistant message of the whole interval (steering or injected work may run
|
||||
* further turns before quiescence), and the outcome reason is the final
|
||||
* turn/end's kind. Idleness is signalled out of band by the caller's
|
||||
* `agent/status` subscription; the stream itself carries no status frame.
|
||||
* @param frames - the mux stream opened before the prompt.
|
||||
* @param sessionId - the headless session.
|
||||
* @param idle - resolves to the final session-event sequence when the agent reaches quiescence.
|
||||
* @param io - process-facing effects for stream diagnostics.
|
||||
* @returns the aggregated outcome.
|
||||
*/
|
||||
async function consumeUntilIdle(
|
||||
frames: AsyncIterable<RpcRequest<MuxFrame>>,
|
||||
sessionId: SessionId,
|
||||
idle: Promise<number>,
|
||||
io: HeadlessIo,
|
||||
): Promise<TurnOutcome> {
|
||||
/** Aggregate the last assistant text and turn outcome in one owned interval. */
|
||||
function summarize(events: readonly SessionEvent[], firstSeq: number): RunOutcome {
|
||||
let started = false
|
||||
let text = ''
|
||||
let reason: string = 'error'
|
||||
let observedSeq = -1
|
||||
let resolveProgress: (() => void) | undefined
|
||||
const streamDone = (async () => {
|
||||
try {
|
||||
for await (const frame of frames) {
|
||||
const payload = frame.payload
|
||||
if (payload.type === 'stream/error') return
|
||||
if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue
|
||||
const event = payload.event
|
||||
observedSeq = event.seq
|
||||
resolveProgress?.()
|
||||
resolveProgress = undefined
|
||||
if (event.type === 'turn/start') {
|
||||
started = true
|
||||
continue
|
||||
}
|
||||
if (!started) continue
|
||||
if (event.type === 'assistant/message') {
|
||||
const joined = event.data.message.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
if (joined !== '') text = joined
|
||||
}
|
||||
if (event.type === 'turn/end') reason = event.data.reason.kind
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
io.stderr.write(`dsh: event stream failed: ${String(error)}\n`)
|
||||
let reason: SessionEvent<'turn/end'>['data']['reason'] | undefined
|
||||
for (const event of events) {
|
||||
if (event.seq < firstSeq) continue
|
||||
if (event.type === 'turn/start') {
|
||||
started = true
|
||||
continue
|
||||
}
|
||||
})()
|
||||
const streamEnded = streamDone.then(() => 'ended' as const)
|
||||
const idleSeq = await idle
|
||||
while (observedSeq < idleSeq) {
|
||||
const progress = new Promise<'progress'>((resolve) => { resolveProgress = () => { resolve('progress') } })
|
||||
if (await Promise.race([progress, streamEnded]) === 'ended') break
|
||||
if (!started) continue
|
||||
if (event.type === 'assistant/message') {
|
||||
const joined = event.data.message.content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
if (joined !== '') text = joined
|
||||
}
|
||||
if (event.type === 'turn/end') reason = event.data.reason
|
||||
}
|
||||
return { text, reason }
|
||||
}
|
||||
|
||||
/** Report an unexpected direct-driver failure and request a failing exit. */
|
||||
function fail(io: HeadlessIo, error: unknown): void {
|
||||
io.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
io.exit(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one headless task to quiescence and request exit (completed → 0, else 1).
|
||||
* @param ctx - plugin context carrying apiProxy, httpServer, and the launcher's headlessIo.
|
||||
* @param config - validated {@link Config}.
|
||||
* Run one task through a freshly created Agent and request process exit.
|
||||
* @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services.
|
||||
* @param task - one-shot task text.
|
||||
* @param io - process-facing effects.
|
||||
*/
|
||||
async function run(ctx: Context, task: string, io: HeadlessIo): Promise<void> {
|
||||
// Loader siblings mount concurrently. Await the complete application before
|
||||
// creating an Agent so its scoped tools and adapters are not half-composed.
|
||||
await ctx.get('loader')?.await()
|
||||
const agents = ctx.get('agents')
|
||||
const defaultModel = ctx.get('agentDefaultModel')
|
||||
const sessions = ctx.get('sessions')
|
||||
// Early process shutdown can dispose the tree while settlement is pending.
|
||||
if (agents === undefined || defaultModel === undefined || sessions === undefined) return
|
||||
|
||||
const selection = defaultModel.currentSelection()
|
||||
const { agent } = await agents.create({
|
||||
sessionId: SessionId(`session-${randomUUID()}`),
|
||||
meta: { cwd: process.cwd() },
|
||||
agentOptions: { provider: selection.provider, model: selection.model },
|
||||
setup: (agentCtx) => {
|
||||
const selected: ModelSelectionRef = { current: selection, assembled: undefined }
|
||||
installModelSelection(agentCtx, selected)
|
||||
},
|
||||
})
|
||||
await agent.whenIdle()
|
||||
const firstSeq = agent.session.seq
|
||||
agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: task }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
await agent.whenIdle()
|
||||
await sessions.flush(agent.session)
|
||||
const outcome = summarize(agent.session.events, firstSeq)
|
||||
io.stdout.write(outcome.text + '\n')
|
||||
if (outcome.reason?.kind === 'error') {
|
||||
io.stderr.write(`dsh: ${outcome.reason.error.code}: ${outcome.reason.error.message}\n`)
|
||||
}
|
||||
io.exit(outcome.reason?.kind === 'completed' ? 0 : 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the one-shot direct driver.
|
||||
* @param ctx - plugin context carrying core services and the launcher-owned IO seam.
|
||||
* @param config - validated task config.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const io = ctx.headlessIo
|
||||
if (io === undefined) {
|
||||
throw new Error('headless-runner: the launcher must provide ctx.headlessIo before the tree mounts')
|
||||
}
|
||||
// Fire-and-forget by design: the run outlives plugin activation, and every
|
||||
// failure path inside ends in io.exit, not a rejection.
|
||||
void (async () => {
|
||||
// The Loader mounts sibling rows concurrently and this plugin's inject
|
||||
// gate covers only apiProxy/httpServer; prompting before the agent loop,
|
||||
// adapters, and tools settle would fail the turn on a half-mounted tree.
|
||||
// The old launcher ran strictly after settled boot — preserve that.
|
||||
// A tree disposed mid-settlement (early SIGTERM) has nothing to run.
|
||||
await ctx.get('loader')?.await()
|
||||
if (ctx.get('httpServer') === undefined) return
|
||||
// The headless session is web-observable while it runs (same composition).
|
||||
io.stderr.write(`dsh: observing at http://127.0.0.1:${String(ctx.httpServer.port)}\n`)
|
||||
const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
|
||||
const created = await unwrap(await api.sessions.create({}), io)
|
||||
// Open the stream before prompting so no frame is lost. The quiescence
|
||||
// anchor below is an in-process ctx subscription, so a remote-carrier
|
||||
// port of this runner must replace it with a wire-visible idle signal.
|
||||
const abort = new AbortController()
|
||||
const frames = api.events.mux({}, abort.signal)
|
||||
const idle = new Promise<number>((resolve) => {
|
||||
ctx.on('agent/status', ({ agent, status }) => {
|
||||
if (agent.id === created.sessionId && status === 'idle') resolve(agent.session.seq - 1)
|
||||
})
|
||||
})
|
||||
const done = consumeUntilIdle(frames, created.sessionId, idle, io)
|
||||
await unwrap(await api.sessions.prompt({
|
||||
sessionId: created.sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: config.task }],
|
||||
}), io)
|
||||
const outcome = await done
|
||||
io.stdout.write(outcome.text + '\n')
|
||||
abort.abort()
|
||||
io.exit(outcome.reason === 'completed' ? 0 : 1)
|
||||
})()
|
||||
void run(ctx, config.task, io).catch((error: unknown) => { fail(io, error) })
|
||||
}
|
||||
|
||||
@@ -1,170 +1,184 @@
|
||||
/**
|
||||
* One-shot runner behavior over a scripted in-process API: idle-to-idle
|
||||
* aggregation (last text of the whole interval), exit-code mapping by the
|
||||
* final turn-end reason, stream-error and RPC-error paths, and the
|
||||
* launcher-owned `ctx.headlessIo` requirement.
|
||||
*/
|
||||
/** Direct one-shot Agent driving, durable aggregation, flushing, and exit mapping. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import AgentDefaultModelService from '@deepseek-ai/dsh-agent-default-model'
|
||||
import { createAssistantMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { apply, Config, type HeadlessIo } from '../src/index.ts'
|
||||
|
||||
interface ScriptedEvent { type: string; seq?: number; time?: number; sessionId?: string; data: Record<string, unknown> }
|
||||
|
||||
let nextSeq = 0
|
||||
/** Stamp the envelope fields the wire schema requires. */
|
||||
function stamped(event: ScriptedEvent): ScriptedEvent {
|
||||
nextSeq += 1
|
||||
return { seq: nextSeq, time: nextSeq, ...event }
|
||||
interface Script {
|
||||
before?(session: Session): void
|
||||
afterPrompt(session: Session, message: UserMessage): Promise<void> | void
|
||||
}
|
||||
|
||||
interface RpcShapedRequest { rpcId: string }
|
||||
|
||||
interface ScriptedApiOptions {
|
||||
promptFails?: boolean
|
||||
framesAfterPrompt?: boolean
|
||||
onPrompt?: () => void
|
||||
function appendTurn(
|
||||
session: Session,
|
||||
turn: number,
|
||||
message: UserMessage,
|
||||
text: string | undefined,
|
||||
completed: boolean,
|
||||
): void {
|
||||
session.append('turn/start', { turn })
|
||||
session.append('step/start', { turn, step: 1 })
|
||||
session.append('user/message', message, { surfaceOp: 'append' })
|
||||
if (text !== undefined) {
|
||||
session.append('assistant/message', {
|
||||
turn,
|
||||
step: 1,
|
||||
message: createAssistantMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { provider: 'test-provider', model: 'test-model' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
session.append('turn/end', {
|
||||
turn,
|
||||
reason: completed
|
||||
? { kind: 'completed' }
|
||||
: { kind: 'aborted', reason: { kind: 'user' } },
|
||||
})
|
||||
}
|
||||
|
||||
/** Build a fake apiProxy (echoing rpcIds like the real gateway) whose mux stream replays `events` for the created session. */
|
||||
function scriptedApi(events: ScriptedEvent[], options: ScriptedApiOptions = {}): unknown {
|
||||
let releaseFrames = (): void => {}
|
||||
const framesReady = options.framesAfterPrompt === true
|
||||
? new Promise<void>((resolve) => { releaseFrames = resolve })
|
||||
: Promise.resolve()
|
||||
const prepared = events.map((event) => {
|
||||
if (event.type === 'stream/error') return { streamError: true } as const
|
||||
const { sessionId = 'S1', ...rest } = event
|
||||
return { streamError: false, sessionId, event: stamped(rest) } as const
|
||||
/** Mount the real registries around a small scripted Agent factory. */
|
||||
async function bench(script: Script): Promise<{
|
||||
ctx: Context
|
||||
run(): Promise<{ code: number; out: string; err: string; order: string[] }>
|
||||
}> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentDefaultModelService, { provider: 'test-provider', model: 'test-model' })
|
||||
ctx.agents.setFactory({
|
||||
async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> {
|
||||
const session = ctx.sessions.create(options.sessionId, {
|
||||
...options.meta === undefined ? {} : { meta: options.meta },
|
||||
})
|
||||
let idle = Promise.resolve()
|
||||
const agent = {} as Agent
|
||||
const agentCtx = ownerCtx.extend({ agent })
|
||||
Object.assign(agent, {
|
||||
id: session.id,
|
||||
options: options.agentOptions ?? {},
|
||||
session,
|
||||
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
||||
status: 'idle',
|
||||
ctx: agentCtx,
|
||||
cancel: () => {},
|
||||
runMaintenance: () => Promise.reject(new Error('not used')),
|
||||
send: () => {},
|
||||
followup: (message: UserMessage) => {
|
||||
agent.inbox.append('next-turn', message)
|
||||
idle = Promise.resolve().then(() => script.afterPrompt(session, message))
|
||||
},
|
||||
steer: () => {},
|
||||
inject: () => {},
|
||||
whenIdle: () => idle,
|
||||
} satisfies Partial<Agent>)
|
||||
await options.setup?.(agentCtx)
|
||||
script.before?.(session)
|
||||
ctx.agents.register(agent)
|
||||
return { agent, dispose: () => Promise.resolve() }
|
||||
},
|
||||
resume: () => Promise.reject(new Error('not used')),
|
||||
})
|
||||
return {
|
||||
sessions: {
|
||||
create: (request: RpcShapedRequest) =>
|
||||
Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { sessionId: 'S1' } } }),
|
||||
prompt: (request: RpcShapedRequest) => {
|
||||
releaseFrames()
|
||||
options.onPrompt?.()
|
||||
return Promise.resolve(options.promptFails === true
|
||||
// A code from the closed wire union: the carrier schema rejects invented codes.
|
||||
? { rpcId: request.rpcId, result: { ok: false, error: { code: 'agent-busy', message: 'agent is busy', details: { reason: 'test' } } } }
|
||||
: { rpcId: request.rpcId, result: { ok: true, value: { accepted: true } } })
|
||||
},
|
||||
},
|
||||
events: {
|
||||
mux: async function* () {
|
||||
await framesReady
|
||||
for (const item of prepared) {
|
||||
if (item.streamError) {
|
||||
yield { rpcId: 'e', payload: { type: 'stream/error', error: { code: 'cancelled', message: 'stream broke', details: {} } } }
|
||||
continue
|
||||
}
|
||||
yield { rpcId: 'e', payload: { type: 'session/event', sessionId: item.sessionId, event: item.event } }
|
||||
ctx,
|
||||
run: async () => {
|
||||
let out = ''
|
||||
let err = ''
|
||||
const order: string[] = []
|
||||
ctx.on('session/flush', () => { order.push('flush') })
|
||||
const exited = new Promise<number>((resolve) => {
|
||||
const io: HeadlessIo = {
|
||||
stdout: { write: (chunk: string) => { out += chunk; return true } },
|
||||
stderr: { write: (chunk: string) => { err += chunk; return true } },
|
||||
exit: (code) => { order.push('exit'); resolve(code) },
|
||||
}
|
||||
},
|
||||
ctx.provide('headlessIo', io)
|
||||
})
|
||||
apply(ctx, { task: 'do the thing' })
|
||||
return { code: await exited, out, err, order }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the runner against a scripted API, emit the idle transition after the
|
||||
* scripted frames drain, and wait for its exit request.
|
||||
*/
|
||||
async function run(
|
||||
events: ScriptedEvent[],
|
||||
options: { promptFails?: boolean; framesAfterPrompt?: boolean; idleInPrompt?: boolean } = {},
|
||||
): Promise<{ code: number; out: string; err: string }> {
|
||||
const ctx = new Context()
|
||||
let out = ''
|
||||
let err = ''
|
||||
const exited = new Promise<number>((resolve) => {
|
||||
const io: HeadlessIo = {
|
||||
stdout: { write: (chunk: string) => { out += chunk; return true } },
|
||||
stderr: { write: (chunk: string) => { err += chunk; return true } },
|
||||
exit: resolve,
|
||||
}
|
||||
ctx.provide('headlessIo', io)
|
||||
})
|
||||
const emitIdle = (): void => {
|
||||
ctx.emit('agent/status', { agent: { id: 'S1', session: { seq: nextSeq + 1 } } as Agent, status: 'idle' })
|
||||
}
|
||||
ctx.provide('apiProxy', scriptedApi(events, {
|
||||
...options.promptFails === undefined ? {} : { promptFails: options.promptFails },
|
||||
...options.framesAfterPrompt === undefined ? {} : { framesAfterPrompt: options.framesAfterPrompt },
|
||||
...options.idleInPrompt === true ? { onPrompt: emitIdle } : {},
|
||||
}) as never)
|
||||
ctx.provide('httpServer', { port: 12345 } as never)
|
||||
apply(ctx, { task: 'do the thing' })
|
||||
// Quiescence is out of band: give the scripted stream a beat to drain, then
|
||||
// flip the agent idle exactly as the loop would. Foreign agents and
|
||||
// non-idle transitions must not settle the run.
|
||||
if (options.idleInPrompt !== true) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
ctx.emit('agent/status', { agent: { id: 'OTHER' } as Agent, status: 'idle' })
|
||||
ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'running' })
|
||||
emitIdle()
|
||||
}
|
||||
const code = await exited
|
||||
await ctx.fiber.dispose()
|
||||
return { code, out, err }
|
||||
}
|
||||
|
||||
const startupTurn: ScriptedEvent = { type: 'turn/start', data: { turn: 0, trigger: { kind: 'startup' } } }
|
||||
const messageTurn: ScriptedEvent = { type: 'turn/start', data: { turn: 1, trigger: { kind: 'message' } } }
|
||||
const text = (turn: number, value: string): ScriptedEvent => ({
|
||||
type: 'assistant/message',
|
||||
data: { turn, message: { content: [{ type: 'text', text: value }] } },
|
||||
})
|
||||
const end = (turn: number, reason: string): ScriptedEvent => ({ type: 'turn/end', data: { turn, reason: { kind: reason } } })
|
||||
|
||||
describe('headless runner', () => {
|
||||
it('aggregates to quiescence: last text wins across turns, final turn-end reason maps to exit 0', async () => {
|
||||
const { code, out, err } = await run([
|
||||
// Frames before the first turn/start are outside the task interval.
|
||||
{ type: 'assistant/message', data: { turn: 0, message: { content: [{ type: 'text', text: 'pre-task noise' }] } } },
|
||||
startupTurn,
|
||||
// Off-session, non-text, and text-empty frames never affect the aggregate.
|
||||
{ type: 'assistant/message', sessionId: 'OTHER', data: { turn: 1, message: { content: [{ type: 'text', text: 'other session' }] } } },
|
||||
{ type: 'assistant/message', data: { turn: 1, message: { content: [{ type: 'tool_call', text: 'ignored' }] } } },
|
||||
text(0, 'draft'),
|
||||
end(0, 'completed'),
|
||||
messageTurn,
|
||||
text(1, 'final answer'),
|
||||
end(1, 'completed'),
|
||||
])
|
||||
expect(code).toBe(0)
|
||||
expect(out).toBe('final answer\n')
|
||||
expect(err).toContain('observing at http://127.0.0.1:12345')
|
||||
it('aggregates the final text across the complete idle-to-idle interval and flushes before exit', async () => {
|
||||
const test = await bench({
|
||||
before(session) {
|
||||
const setupMessage = {
|
||||
role: 'user', content: [{ type: 'text', text: 'setup' }], source: { kind: 'user' }, id: 'setup',
|
||||
} as UserMessage
|
||||
appendTurn(session, 0, setupMessage, 'pre-task noise', true)
|
||||
},
|
||||
async afterPrompt(session, message) {
|
||||
await Promise.resolve()
|
||||
appendTurn(session, 1, message, '', true)
|
||||
appendTurn(session, 2, message, 'final answer', true)
|
||||
},
|
||||
})
|
||||
const result = await test.run()
|
||||
expect(result).toEqual({
|
||||
code: 0,
|
||||
out: 'final answer\n',
|
||||
err: '',
|
||||
order: ['flush', 'exit'],
|
||||
})
|
||||
await test.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('consumes through the idle sequence when queued frames arrive after the status transition', async () => {
|
||||
const { code, out } = await run(
|
||||
[messageTurn, text(1, 'race-free answer'), end(1, 'completed')],
|
||||
{ framesAfterPrompt: true, idleInPrompt: true },
|
||||
)
|
||||
expect(code).toBe(0)
|
||||
expect(out).toBe('race-free answer\n')
|
||||
it('waits for asynchronously appended events instead of racing Agent idleness', async () => {
|
||||
const test = await bench({
|
||||
afterPrompt: async (session, message) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
appendTurn(session, 1, message, 'race-free answer', true)
|
||||
},
|
||||
})
|
||||
expect(await test.run()).toMatchObject({ code: 0, out: 'race-free answer\n', err: '' })
|
||||
await test.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('exits 1 when the final turn ends for any other reason', async () => {
|
||||
const { code } = await run([messageTurn, end(1, 'aborted')])
|
||||
expect(code).toBe(1)
|
||||
it('exits 1 when the final turn does not complete', async () => {
|
||||
const test = await bench({
|
||||
afterPrompt(session, message) { appendTurn(session, 1, message, undefined, false) },
|
||||
})
|
||||
expect(await test.run()).toMatchObject({ code: 1, out: '\n', err: '' })
|
||||
await test.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('exits 1 when no turn ever starts (idle without work)', async () => {
|
||||
const { code, out } = await run([])
|
||||
expect(code).toBe(1)
|
||||
expect(out).toBe('\n')
|
||||
it('prints the durable model failure when the final turn ends in error', async () => {
|
||||
const test = await bench({
|
||||
afterPrompt(session, message) {
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('user/message', message, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', {
|
||||
turn: 1,
|
||||
reason: { kind: 'error', error: { code: 'SERVER', message: 'provider unavailable' } },
|
||||
})
|
||||
},
|
||||
})
|
||||
expect(await test.run()).toMatchObject({
|
||||
code: 1,
|
||||
out: '\n',
|
||||
err: 'dsh: SERVER: provider unavailable\n',
|
||||
})
|
||||
await test.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps the error outcome after a stream error ends the frame consumer early', async () => {
|
||||
const { code } = await run([messageTurn, { type: 'stream/error', data: {} }, end(1, 'completed')])
|
||||
// The consumer stopped at the stream error; the completed turn-end after
|
||||
// it is never observed, so the reason stays 'error'.
|
||||
expect(code).toBe(1)
|
||||
it('exits 1 when the owned interval contains no turn', async () => {
|
||||
const test = await bench({ afterPrompt: () => {} })
|
||||
expect(await test.run()).toMatchObject({ code: 1, out: '\n', err: '' })
|
||||
await test.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('prints an RPC business error and exits 1 without waiting for idle', async () => {
|
||||
it('reports a direct Agent creation failure', async () => {
|
||||
const ctx = new Context()
|
||||
let err = ''
|
||||
const exited = new Promise<number>((resolve) => {
|
||||
@@ -174,15 +188,16 @@ describe('headless runner', () => {
|
||||
exit: resolve,
|
||||
} satisfies HeadlessIo)
|
||||
})
|
||||
ctx.provide('apiProxy', scriptedApi([messageTurn, end(1, 'completed')], { promptFails: true }) as never)
|
||||
ctx.provide('httpServer', { port: 1 } as never)
|
||||
ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
|
||||
ctx.provide('sessions', { flush: () => Promise.resolve(true) } as never)
|
||||
ctx.provide('agents', { create: () => Promise.reject(new Error('factory exploded')) } as never)
|
||||
apply(ctx, { task: 't' })
|
||||
expect(await exited).toBe(1)
|
||||
expect(err).toContain('agent-busy')
|
||||
expect(err).toBe('dsh: factory exploded\n')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('reports the stream-failed diagnostic when the event channel dies, still settling at idle', async () => {
|
||||
it('stringifies a non-Error Agent creation failure', async () => {
|
||||
const ctx = new Context()
|
||||
let err = ''
|
||||
const exited = new Promise<number>((resolve) => {
|
||||
@@ -192,67 +207,52 @@ describe('headless runner', () => {
|
||||
exit: resolve,
|
||||
} satisfies HeadlessIo)
|
||||
})
|
||||
ctx.provide('apiProxy', {
|
||||
sessions: {
|
||||
create: (request: RpcShapedRequest) =>
|
||||
Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { sessionId: 'S1' } } }),
|
||||
prompt: (request: RpcShapedRequest) =>
|
||||
Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { accepted: true } } }),
|
||||
ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
|
||||
ctx.provide('sessions', { flush: () => Promise.resolve(true) } as never)
|
||||
const rejected = {
|
||||
then(_resolve: (value: never) => void, reject: (reason: unknown) => void): void {
|
||||
reject('factory exploded')
|
||||
},
|
||||
events: {
|
||||
// Synchronous throw: the SSE response never forms, so the client-side
|
||||
// iterable rejects — the runner's own catch path, not a carrier frame.
|
||||
mux: () => { throw new Error('channel exploded') },
|
||||
},
|
||||
} as never)
|
||||
ctx.provide('httpServer', { port: 1 } as never)
|
||||
}
|
||||
ctx.provide('agents', { create: () => rejected } as never)
|
||||
apply(ctx, { task: 't' })
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
ctx.emit('agent/status', { agent: { id: 'S1', session: { seq: nextSeq + 1 } } as Agent, status: 'idle' })
|
||||
expect(await exited).toBe(1)
|
||||
expect(err).toContain('event stream failed')
|
||||
expect(err).toBe('dsh: factory exploded\n')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('waits for Loader settlement and abandons the run when the tree died during it', async () => {
|
||||
it('abandons a run when the tree is disposed during Loader settlement', async () => {
|
||||
const ctx = new Context()
|
||||
let err = ''
|
||||
let exited = false
|
||||
ctx.provide('headlessIo', {
|
||||
stdout: { write: () => true },
|
||||
stderr: { write: (chunk: string) => { err += chunk; return true } },
|
||||
stderr: { write: () => true },
|
||||
exit: () => { exited = true },
|
||||
} satisfies HeadlessIo)
|
||||
ctx.provide('apiProxy', scriptedApi([]) as never)
|
||||
// The webserver is provided by a child fiber whose disposal (early
|
||||
// SIGTERM during the boot window) removes the service; settlement
|
||||
// resolves only afterwards, and the runner must abandon rather than
|
||||
// crash on the torn-down port read.
|
||||
const webserverFiber = ctx.plugin((childCtx: Context) => {
|
||||
childCtx.provide('httpServer', { port: 1 } as never)
|
||||
const services = ctx.plugin((child: Context) => {
|
||||
child.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
|
||||
child.provide('sessions', {} as never)
|
||||
child.provide('agents', {} as never)
|
||||
})
|
||||
await webserverFiber
|
||||
await services
|
||||
let release: () => void
|
||||
const settlement = new Promise<void>((resolve) => { release = resolve })
|
||||
ctx.provide('loader', { await: () => settlement } as never)
|
||||
apply(ctx, { task: 't' })
|
||||
await webserverFiber.dispose()
|
||||
await services.dispose()
|
||||
release!()
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
expect(err).toBe('')
|
||||
expect(exited).toBe(false)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('fails loud without the launcher-owned headlessIo seam', () => {
|
||||
const ctx = new Context()
|
||||
ctx.provide('apiProxy', scriptedApi([]) as never)
|
||||
ctx.provide('httpServer', { port: 1 } as never)
|
||||
expect(() => { apply(ctx, { task: 't' }) }).toThrow('must provide ctx.headlessIo')
|
||||
})
|
||||
|
||||
it('validates config: the task is required', () => {
|
||||
expect(() => new Config({ } as never)).toThrow()
|
||||
expect(() => new Config({} as never)).toThrow()
|
||||
expect(new Config({ task: 'x' })).toEqual({ task: 'x' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,15 +17,15 @@
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../host/apiproxy"
|
||||
},
|
||||
{
|
||||
"path": "../../host/webserver"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent-default-model"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md
|
||||
README.md: dc35cb4b596b265b70cea81aa5d6784fc1eff65b
|
||||
README.zh.md: 0ffc5cdaf1a98e5df11ef042c7d15a994515c170
|
||||
README.md: d89ae4a7e28506166498caf0032f864bbb109cc5
|
||||
README.zh.md: 746ec2e8b6748a0d72f697d0aea5f3809e7106ee
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's own `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin owns what used to be launcher code: it resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports (workspace knowledge of this bundle, never user config), mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. The `dsh web` launcher alias patches `mode`/`lanAddresses` and the flag family over these rows; [`dsh-headless`](../headless/README.md) layers on top, silences the URL line, and disables the surface context.
|
||||
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. The `dsh web` launcher alias patches `mode`/`lanAddresses` and the flag family over these rows. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包自己的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件接管了原先属于启动器的代码:它通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist(这是本组合包的 workspace 知识,绝不是用户配置),在其上挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。`dsh web` 启动器别名把 `mode`/`lanAddresses` 与相应 flag 家族 patch 到这些行上;[`dsh-headless`](../headless/README.md) 再叠加一层,关闭 URL 行并禁用表层上下文。
|
||||
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。`dsh web` 启动器别名把 `mode`/`lanAddresses` 与相应 flag 家族 patch 到这些行上。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -10,7 +10,7 @@ dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
当 `surfaceContext` 为 true 时,全局段落 `app:web-surface`(顺序 −98)向模型说明 GUI:规范的本地 URL、「this page」指代什么、当前模式下 HMR(热模块替换)/重建的更新契约,以及不要启动替代服务器的指令。`DSH_WEB_URL` 与 `DSH_WEB_MODE` 还会连同各自描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,该提示词段和这些变量都不会注册。
|
||||
当 `surfaceContext` 为 true 时,全局段落 `app:web-surface`(顺序 −98)向模型说明 GUI:规范的本地 URL、「this page」指代什么、当前模式下 HMR(热模块替换)/重建的更新约定,以及不要启动替代服务器的指令。`DSH_WEB_URL` 与 `DSH_WEB_MODE` 还会连同各自描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,该提示词段和这些变量都不会注册。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -22,5 +22,5 @@ dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **前端 dist 必须已构建**:对 dist 的 `require.resolve` 在激活时大声失败并给出构建提示;没有从源码直接服务的回退路径。
|
||||
- **前端 dist 必须已构建**:对 dist 的 `require.resolve` 在激活时明确报错并给出构建提示;没有从源码直接服务的回退路径。
|
||||
- **`lanAddresses` 是启动期快照**:启动后的网卡变化不会重新公告;打印的 LAN URL 始终与配置的信任栅栏一致。
|
||||
|
||||
@@ -72,13 +72,9 @@
|
||||
name: '@deepseek-ai/dsh-host-directory-picker-auto'
|
||||
|
||||
# The API gateway: the transport-agnostic dispatch face every client shape
|
||||
# shares. provider/model are the host default routing — the profile json's
|
||||
# mapping target (user config overrides these engineering defaults).
|
||||
# shares. The base layer's agent-default-model service owns the default model.
|
||||
- id: api-gateway
|
||||
name: '@deepseek-ai/dsh-host-apiproxy'
|
||||
config:
|
||||
provider: deepseek-official
|
||||
model: deepseek-v4-flash
|
||||
|
||||
# ── layer 2: transport/service ──────────────────────────────────────────────
|
||||
|
||||
@@ -109,8 +105,8 @@
|
||||
|
||||
# Dual-face: node half scans this very tree for dshClient rows, composes
|
||||
# window.__DSH_BOOT__, serves /plugins/<id>/client.js; browser half is the
|
||||
# module table the shell kernel constructs before cordis exists (§4.7 —
|
||||
# adopted as a plugin entry by the kernel, never fetched).
|
||||
# module table the shell kernel constructs before cordis exists (adopted
|
||||
# as a plugin entry by the kernel, never fetched).
|
||||
- id: modules
|
||||
name: '@deepseek-ai/dsh-client-modules'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @deepseek-ai/dsh-web-app — the browser-surface bundle's runtime glue plugin
|
||||
* plus the bundle patch (`cordis.patch.yml`, declared by the `dsh.bundle.patch`
|
||||
* manifest field). The plugin owns what used to be launcher code: it resolves
|
||||
* manifest field). The plugin owns the browser-surface glue: it resolves
|
||||
* the built frontend dist (workspace knowledge of this bundle, never user
|
||||
* config), mounts the `frontend-static` fallback owner over it, registers the
|
||||
* web-surface prompt section and the bash-visible web runtime variables, and
|
||||
@@ -101,7 +101,7 @@ function resolveDistIndex(): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam: hosts with no built frontend dist substitute the resolver; production never touches this. */
|
||||
/** Test hook: hosts with no built frontend dist substitute the resolver; production never touches this. */
|
||||
export const internals: { resolveDistIndex: () => string } = { resolveDistIndex }
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Web runtime glue behavior: dist resolution through the bundle's own seam,
|
||||
* Web runtime glue behavior: dist resolution through the bundle's own hook,
|
||||
* the frontend-static child claiming the fallback seat, the web-surface
|
||||
* prompt section and bash runtime variables, and URL-line printing with the
|
||||
* launcher's LAN snapshot.
|
||||
@@ -184,7 +184,7 @@ describe('web-app runtime glue', () => {
|
||||
})
|
||||
|
||||
it('resolves the real built frontend dist through the package exports, failing loud unbuilt', () => {
|
||||
// The production resolver (not the test seam). A built checkout resolves
|
||||
// The production resolver (not the test hook). A built checkout resolves
|
||||
// the frontend package's index.html; a dist-less one (the CI coverage
|
||||
// lane runs before any build) must fail with the build hint, never a
|
||||
// silent fallback.
|
||||
|
||||
@@ -22,8 +22,8 @@ How live data reaches render code, and what may cross a business boundary:
|
||||
|
||||
1. **Everything a render reads that can change outside React arrives through a framework hook** (rule 4 above). Event-handler code may read live snapshots (e.g. `keyboard.snapshot`); render code subscribes.
|
||||
2. **Business components contain no subscription machinery** — no `useSyncExternalStore`, no manual subscribe wiring, no mirroring an external snapshot into local state or a second store. Give each reactive fact its owning channel instead: registrant-private → the inject `hooks` compartment; cross-entry or remount-surviving → a declared store; per-session standard → `sessions.provide`.
|
||||
3. **Data-access ladder** — resolve needs in this order: framework hooks (standing seats + provide/inject-bound `use<Name>`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework seam and needs main-thread arbitration.
|
||||
4. **Contract currency is JSON-able data and callbacks.** Everything crossing a business boundary (owner props, inject faces, store state, provide contributions) is plain serializable data or a callback over such data; the inject `hooks` compartment is the one sanctioned carrier of bare observables, and components never see those either. ReactNode is not a currency: route render content through a slot; no new ReactNode-valued owner props or inject members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` seats are grandfathered and get migrated to slots progressively).
|
||||
3. **Data-access ladder** — resolve needs in this order: framework hooks (standing seats + provide/inject-bound `use<Name>`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework extension point and needs main-thread arbitration.
|
||||
4. **Contract currency is JSON-able data and callbacks.** Everything crossing a business boundary (owner props, inject faces, store state, provide contributions) is plain serializable data or a callback over such data; the inject `hooks` compartment is the one sanctioned carrier of bare observables, and components never see those either. ReactNode is not a currency: route render content through a slot; no new ReactNode-valued owner props or inject members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` seats are exceptions pending migration to slots).
|
||||
5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves).
|
||||
6. **Whoever rebuilds a published value republishes it through the same source in the same step**, and a registration path that can run after consumers exist notifies the live consumers as part of registering.
|
||||
|
||||
@@ -54,6 +54,12 @@ Non-negotiables across the layers:
|
||||
- **Notifier publication discipline**: `notifyNow` is only the direct echo of a user gesture; structural updates use microtask-batched `markDirty`, while visible streaming chunks use cumulative `markFrameDirty`. See `runtime/src/client/sessions/notifier.ts`.
|
||||
- **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule).
|
||||
|
||||
## Conversation Node discipline
|
||||
|
||||
- A Chat business feature registers one `ConversationNodeDefinition` and its keyed `conversation.chat.node` renderer; do not add its event switch or fold to `Session`, `SessionManager`, or a central built-in dispatcher. Follow the [Conversation Node cookbook](../../docs/cookbook/adding-a-conversation-node.md).
|
||||
- `match(event)` reads only the current event. Every event in a multi-event Context carries or independently derives the same stable business id; `update` folds one Match into State and remains deterministically replayable by log `seq`.
|
||||
- The append hot path and renderers never scan the full event window, Contexts, or Chat Nodes. Accumulate in State, publish same-Turn/Step facts through `buildLocationData()`, and consume final Node data or constrained Location hooks.
|
||||
|
||||
## Directory regime (plugin packages)
|
||||
|
||||
One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits by future package boundaries — ui-conversation is the exemplar: `contract/` (the only shared face), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through `slots.register` in `apply` — never module-level side effects.
|
||||
@@ -83,7 +89,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore
|
||||
|
||||
## New plugin package checklist
|
||||
|
||||
Bringing up a new `packages/client/<name>` plugin package (ui-workspace is the latest walked example; ui-sidebar/ui-question are good skeletons to copy):
|
||||
Bringing up a new `packages/client/<name>` plugin package (ui-workspace is a complete example; ui-sidebar/ui-question are minimal skeletons):
|
||||
|
||||
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
|
||||
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the healed `$DSH_HOME/profiles/node_modules` fallback, which mirrors the app's and each bundle's declared dependencies — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/README.md
|
||||
README.md: b6fa426fbe541e2b22d2bf5f19d4397361cf0899
|
||||
README.zh.md: 5a55bb8c2c31b5215fc73e75e1c4f3aca79add64
|
||||
README.md: 56b9363cc724515ecbd11127ea4c13aba84283df
|
||||
README.zh.md: a3fe1a978de7ab5935ec527d115278703cbebcd4
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user