Merge remote-tracking branch 'origin/master' into worktree/drop-create-by-name
# Conflicts: # .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml # .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml # .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md # .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml # .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md # .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md # apps/cli/reference/README.i18n.yaml # docs/config-catalog.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/api-proxy-approval.spec.ts # packages/host/apiproxy/tests/api-proxy-blank.spec.ts # packages/host/apiproxy/tests/api-proxy-cold.spec.ts # packages/host/apiproxy/tests/api-proxy-commands.spec.ts # packages/host/apiproxy/tests/api-proxy-config.spec.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/host/apiproxy/tests/api-proxy-projections.spec.ts # packages/host/apiproxy/tests/api-proxy-question.spec.ts # packages/host/apiproxy/tests/api-proxy-rename.spec.ts # packages/host/apiproxy/tests/api-proxy-search.spec.ts # packages/host/apiproxy/tests/api-proxy-subagents.spec.ts # packages/host/apiproxy/tests/api-proxy-view.spec.ts # packages/host/apiproxy/tests/api-proxy-workspace.spec.ts # packages/todo/tool-todo/tests/projection.spec.ts # scripts/hero-composer-dom-continuity.mjs
This commit is contained in:
@@ -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: 2a87b01ad4819750a58163f8c472e61ea633588e
|
||||
README.zh.md: dc79895355546812aa3371487190724f169c6260
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
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.
|
||||
|
||||
Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only bash stack (`bash-sandbox`/`tool-bash`) and inserts the sandbox-confined PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox`, `@deepseek-ai/dsh-tool-pwsh`). The permission surface stays exactly as on POSIX: `sandbox`/`sandbox-policy` enforce the file-effect policy through the Windows ACL restricted-token runner (the win32 chain of `dsh-sandbox-local` → `@deepseek-ai/dsh-sandbox-windows-acl`), the permission switcher and the approval service run unchanged, and `fs-sandbox` keeps fencing `ctx.fs` writes — mounting `dsh-fs-local` alongside it would double-register `ctx.fs` and fail the load. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the unconfined local pwsh executor or full access overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts never receive it.
|
||||
|
||||
The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it.
|
||||
|
||||
@@ -17,3 +19,4 @@ None directly; each inserted row's package owns its effect.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A patch replaces whole row configs** — profile overrides must restate every field a row keeps; there is no deep-merge layer.
|
||||
- **The Windows temp grant is a private per-session subdirectory** — `workspace-write` confines writes to the workspace plus the session's own temp subdirectory (`<temp>\dsh-<hash>`, TMP/TEMP rewritten for confined children); `read-only` grants nothing. See `@deepseek-ai/dsh-sandbox-windows-acl`.
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
[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,绝不通过代码。
|
||||
|
||||
启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的 bash 栈(`bash-sandbox`/`tool-bash`),并插入沙盒受限的 PowerShell 栈(`@deepseek-ai/dsh-pwsh-sandbox`、`@deepseek-ai/dsh-tool-pwsh`)。权限面与 POSIX 完全一致:`sandbox`/`sandbox-policy` 通过 Windows ACL 受限令牌 runner(`dsh-sandbox-local` 的 win32 链 → `@deepseek-ai/dsh-sandbox-windows-acl`)执行文件效果策略,权限切换器与 approval 服务原样运行,`fs-sandbox` 继续围栏 `ctx.fs` 写入——在其旁再挂载 `dsh-fs-local` 会重复注册 `ctx.fs` 并在加载时失败。启动器在 win32 主机上把该层应用于 bundle 层与用户层之间;偏好不限权本地 pwsh 执行器或完整访问的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)。POSIX 主机永远不会收到它。
|
||||
|
||||
行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。
|
||||
|
||||
@@ -17,3 +19,4 @@
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **patch 会替换整行 `config`**:profile 覆盖必须重述该行需要保留的每个字段;不存在深度合并层。
|
||||
- **Windows 的临时目录授权是按会话的私有子目录**——`workspace-write` 把写入限制在工作区与会话自己的 temp 子目录(`<temp>\dsh-<hash>`,受限子进程的 TMP/TEMP 被改写);`read-only` 不授予任何写入。见 `@deepseek-ai/dsh-sandbox-windows-acl`。
|
||||
|
||||
@@ -34,6 +34,15 @@
|
||||
- id: session
|
||||
name: '@deepseek-ai/dsh-session'
|
||||
|
||||
- id: typert
|
||||
name: '@deepseek-ai/dsh-typert-registry'
|
||||
|
||||
- id: typert-loader
|
||||
name: '@deepseek-ai/dsh-typert-loader'
|
||||
|
||||
- id: typert-gateway
|
||||
name: '@deepseek-ai/dsh-api-gateway'
|
||||
|
||||
- id: session-title
|
||||
name: '@deepseek-ai/dsh-session-title'
|
||||
config:
|
||||
@@ -56,6 +65,14 @@
|
||||
- id: agent
|
||||
name: '@deepseek-ai/dsh-agent'
|
||||
|
||||
# The transport-independent default for Agents created by entry points.
|
||||
# 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'
|
||||
|
||||
@@ -68,12 +85,10 @@
|
||||
- id: settings
|
||||
name: '@deepseek-ai/dsh-settings-local'
|
||||
|
||||
# Credential store: the live process environment over `$DSH_HOME/.env`
|
||||
# (owner-only file, hot-reloaded). Adapters resolve their key references
|
||||
# through it at each request, so no key is inlined in this file. The web
|
||||
# Models page's key inputs write it through `credentials.set`; nothing hoists
|
||||
# the document into the process environment, which would make every stored key
|
||||
# read as an unrotatable ambient override.
|
||||
# Credential sources: inherited environment over the managed
|
||||
# `$DSH_HOME/.credentials.yaml`, with project and user `.env` fallbacks.
|
||||
# Adapters resolve references per request; the Models page writes only the
|
||||
# managed document, which is never materialized into the process environment.
|
||||
- id: credentials
|
||||
name: '@deepseek-ai/dsh-credentials-local'
|
||||
|
||||
@@ -211,12 +226,19 @@
|
||||
- id: skill-local
|
||||
name: '@deepseek-ai/dsh-skill-local'
|
||||
|
||||
- id: skill-badge
|
||||
name: '@deepseek-ai/dsh-skill-badge'
|
||||
disabled: true
|
||||
|
||||
- id: tool-skill
|
||||
name: '@deepseek-ai/dsh-tool-skill'
|
||||
|
||||
- id: commands
|
||||
name: '@deepseek-ai/dsh-commands'
|
||||
|
||||
- id: command-feedback
|
||||
name: '@deepseek-ai/dsh-command-feedback'
|
||||
|
||||
- id: goal
|
||||
name: '@deepseek-ai/dsh-goal'
|
||||
|
||||
@@ -370,7 +392,6 @@
|
||||
name: '@deepseek-ai/dsh-web-search-deepseek'
|
||||
config:
|
||||
apiKeyEnv: DEEPSEEK_API_KEY
|
||||
baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
|
||||
|
||||
- id: tool-web
|
||||
name: '@deepseek-ai/dsh-tool-web'
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./cordis.patch.yml": "./cordis.patch.yml",
|
||||
"./windows.cordis.patch.yml": "./windows.cordis.patch.yml",
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
@@ -23,6 +24,7 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"cordis.patch.yml",
|
||||
"windows.cordis.patch.yml",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
@@ -35,25 +37,30 @@
|
||||
"@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-bash-env": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-command-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-command-feedback": "workspace:^",
|
||||
"@deepseek-ai/dsh-command-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact-basic": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-permission": "workspace:^",
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-pwsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-repeat-tool-guard": "workspace:^",
|
||||
"@deepseek-ai/dsh-repository-plugin": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
@@ -68,6 +75,7 @@
|
||||
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill-badge": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-spill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-spill-policy": "workspace:^",
|
||||
@@ -83,6 +91,7 @@
|
||||
"@deepseek-ai/dsh-tool-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-pwsh": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-ralph": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
|
||||
@@ -94,6 +103,8 @@
|
||||
"@deepseek-ai/dsh-tool-web": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"@deepseek-ai/dsh-web": "workspace:^",
|
||||
|
||||
@@ -13,13 +13,51 @@ import { entryListSchema } from '@cordisjs/plugin-include'
|
||||
describe('dsh-base bundle', () => {
|
||||
it('declares a parseable patch list through the dsh.bundle.patch manifest field', () => {
|
||||
const root = fileURLToPath(new URL('..', import.meta.url))
|
||||
const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { dsh?: { bundle?: { patch?: string } } }
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(resolve(root, 'package.json'), 'utf8'),
|
||||
) as { dsh?: { bundle?: { patch?: string } } }
|
||||
expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml')
|
||||
const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'), { schema: entryListSchema })
|
||||
const parsed = yaml.load(
|
||||
readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'),
|
||||
{ schema: entryListSchema },
|
||||
)
|
||||
expect(Array.isArray(parsed)).toBe(true)
|
||||
// The base layer is one insert list over the empty profile root.
|
||||
const rows = (parsed as { insert?: { id?: string }[] }[]).flatMap(patch => patch.insert ?? [])
|
||||
const rows = (parsed as { insert?: { id?: string }[] }[]).flatMap(
|
||||
patch => patch.insert ?? [],
|
||||
)
|
||||
expect(rows.length).toBeGreaterThan(50)
|
||||
expect(rows.some(row => row.id === 'agent-loop')).toBe(true)
|
||||
})
|
||||
|
||||
it('ships the Windows platform layer as the confined pwsh roster over the ACL runner chain', () => {
|
||||
const root = fileURLToPath(new URL('..', import.meta.url))
|
||||
const parsed = yaml.load(
|
||||
readFileSync(resolve(root, 'windows.cordis.patch.yml'), 'utf8'),
|
||||
{ schema: entryListSchema },
|
||||
) as {
|
||||
id?: string
|
||||
disabled?: boolean
|
||||
insert?: { id?: string; name?: string }[]
|
||||
config?: { policy?: string }
|
||||
}[]
|
||||
const disables = parsed
|
||||
.filter(patch => patch.disabled === true)
|
||||
.map(patch => patch.id)
|
||||
// Only the POSIX bash stack is disabled: the Windows roster confines the
|
||||
// pwsh executor through the ACL runner chain, so the sandbox/policy rows,
|
||||
// the permission switcher, fs-sandbox, and the approval service all stay
|
||||
// enabled exactly as on POSIX — only the shell is swapped.
|
||||
expect(disables).toEqual(['bash-sandbox', 'tool-bash'])
|
||||
const inserted = parsed
|
||||
.flatMap(patch => patch.insert ?? [])
|
||||
.map(row => row.id)
|
||||
expect(inserted).toEqual(['pwsh-sandbox', 'tool-pwsh'])
|
||||
// The patch no longer touches the permission/approval surface at all.
|
||||
expect(parsed.find(patch => patch.id === 'approval')).toBeUndefined()
|
||||
expect(parsed.find(patch => patch.id === 'permission')).toBeUndefined()
|
||||
expect(parsed.find(patch => patch.id === 'sandbox')).toBeUndefined()
|
||||
expect(parsed.find(patch => patch.id === 'sandbox-policy')).toBeUndefined()
|
||||
expect(parsed.find(patch => patch.id === 'fs-sandbox')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
1
packages/bundle/base/tests/fixtures/root.cordis.yml
vendored
Normal file
1
packages/bundle/base/tests/fixtures/root.cordis.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
31
packages/bundle/base/windows.cordis.patch.yml
Normal file
31
packages/bundle/base/windows.cordis.patch.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
# The dsh-base Windows platform layer: applied by the dsh launcher on win32
|
||||
# hosts, between the bundle layers and the user layers. Windows confines
|
||||
# through the ACL restricted-token runner (the win32 chain of
|
||||
# dsh-sandbox-local → @deepseek-ai/dsh-sandbox-windows-acl), so the shipped
|
||||
# stack is the SANDBOXED PowerShell executor plus the full permission
|
||||
# surface: sandbox/sandbox-policy enforce the file-effect policy, the
|
||||
# permission switcher and the approval service run exactly as on POSIX, and
|
||||
# the fs row stays the base's sandboxed provider (fs-sandbox) — mounting
|
||||
# dsh-fs-local alongside it would double-register ctx.fs and fail the load.
|
||||
# Only the POSIX bash
|
||||
# stack (bash-sandbox/tool-bash) is disabled — bash has no Windows runner.
|
||||
# A Windows host that prefers the unconfined local pwsh executor or full
|
||||
# access overrides these rows through its profile or home cordis.patch.yml.
|
||||
# The bash-restore recipe must be complete: disable pwsh-sandbox and
|
||||
# tool-pwsh AND re-enable bash-sandbox and tool-bash — both executor
|
||||
# families register the same 'bash' service, so re-enabling the bash rows
|
||||
# while pwsh-sandbox stays inserted fails loud at load on a duplicate
|
||||
# registration.
|
||||
|
||||
- id: bash-sandbox
|
||||
disabled: true
|
||||
|
||||
- id: tool-bash
|
||||
disabled: true
|
||||
|
||||
- insert:
|
||||
- id: pwsh-sandbox
|
||||
name: '@deepseek-ai/dsh-pwsh-sandbox'
|
||||
|
||||
- id: tool-pwsh
|
||||
name: '@deepseek-ai/dsh-tool-pwsh'
|
||||
@@ -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: d08fb08e2aca3c4e5ccd733b37fc415d492974ca
|
||||
README.zh.md: 99a64ef04c4fd8fb0c6a979d3f09f1bd98b434a0
|
||||
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), 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 --profile headless "task"`), and fails loud when a task is given to a profile without 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 链路都会真实运行)驱动一个任务轮次,聚合该轮次最终的 assistant 文本,写到 stdout,再经启动器提供的 `ctx.headlessIo` seam 请求退出(完成 → 0,否则 1)。Web 组合保持挂载,因此运行中的会话可在浏览器中通过 stderr 公告的 URL 观察。启动器把任务文本 patch 进来(`dsh --profile headless "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,37 +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 --profile headless "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
|
||||
}
|
||||
|
||||
@@ -39,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 }
|
||||
@@ -57,117 +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 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<void>,
|
||||
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'
|
||||
void (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
|
||||
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
|
||||
}
|
||||
})()
|
||||
await idle
|
||||
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<void>((resolve) => {
|
||||
ctx.on('agent/status', ({ agent, status }) => {
|
||||
if (agent.id === created.sessionId && status === 'idle') resolve()
|
||||
})
|
||||
})
|
||||
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,130 +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 }
|
||||
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: { promptFails?: boolean } = {}): unknown {
|
||||
return {
|
||||
sessions: {
|
||||
create: (request: RpcShapedRequest) =>
|
||||
Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { sessionId: 'S1' } } }),
|
||||
prompt: (request: RpcShapedRequest) => 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 } } }),
|
||||
/** 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() }
|
||||
},
|
||||
events: {
|
||||
mux: async function* () {
|
||||
for (const event of events) {
|
||||
if (event.type === 'stream/error') {
|
||||
yield { rpcId: 'e', payload: { type: 'stream/error', error: { code: 'cancelled', message: 'stream broke', details: {} } } }
|
||||
continue
|
||||
}
|
||||
const { sessionId = 'S1', ...rest } = event
|
||||
yield { rpcId: 'e', payload: { type: 'session/event', sessionId, event: stamped(rest) } }
|
||||
resume: () => Promise.reject(new Error('not used')),
|
||||
})
|
||||
return {
|
||||
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 } = {}): 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)
|
||||
})
|
||||
ctx.provide('apiProxy', scriptedApi(events, options) 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.
|
||||
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' })
|
||||
ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'idle' })
|
||||
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('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('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 no turn ever starts (idle without work)', async () => {
|
||||
const { code, out } = await run([])
|
||||
expect(code).toBe(1)
|
||||
expect(out).toBe('\n')
|
||||
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('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('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('prints an RPC business error and exits 1 without waiting for idle', async () => {
|
||||
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('reports a direct Agent creation failure', async () => {
|
||||
const ctx = new Context()
|
||||
let err = ''
|
||||
const exited = new Promise<number>((resolve) => {
|
||||
@@ -134,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) => {
|
||||
@@ -152,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' } 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 始终与配置的信任栅栏一致。
|
||||
|
||||
@@ -35,11 +35,6 @@
|
||||
# once the web UI owns the choice per session.
|
||||
mode: !!js process.env.DSH_TOOLS_MODE
|
||||
|
||||
- id: llm-deepseek
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
baseURL: !!js process.env.DEEPSEEK_BASE_URL
|
||||
|
||||
# ── web-only host rows, the transport layer, and the browser roster ─────────
|
||||
|
||||
# `dshClient` rows are the browser roster the modules node half scans into
|
||||
@@ -77,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 ──────────────────────────────────────────────
|
||||
|
||||
@@ -114,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'
|
||||
|
||||
@@ -124,6 +115,9 @@
|
||||
- id: connection
|
||||
name: '@deepseek-ai/dsh-client-connection'
|
||||
|
||||
- id: api-remotes
|
||||
name: '@deepseek-ai/dsh-api-remotes'
|
||||
|
||||
- id: client-runtime
|
||||
name: '@deepseek-ai/dsh-client-runtime'
|
||||
|
||||
@@ -151,6 +145,15 @@
|
||||
- id: ui-conversation
|
||||
name: '@deepseek-ai/dsh-client-ui-conversation'
|
||||
|
||||
# Tool call tree, generic fallback, and keyed business Tool views.
|
||||
- id: ui-tool
|
||||
name: '@deepseek-ai/dsh-client-ui-tool'
|
||||
|
||||
# Turn tail: the produced-files row under each closing assistant message.
|
||||
# Remove this entry to turn the surface off; the tail hole renders empty.
|
||||
- id: ui-deliverables
|
||||
name: '@deepseek-ai/dsh-client-ui-deliverables'
|
||||
|
||||
|
||||
- id: ui-workspace
|
||||
name: '@deepseek-ai/dsh-client-ui-workspace'
|
||||
@@ -180,6 +183,11 @@
|
||||
- id: ui-permission
|
||||
name: '@deepseek-ai/dsh-client-ui-permission'
|
||||
|
||||
# The agent-preset row in General settings: the default preset for
|
||||
# sessions created later. Absent a roster it renders nothing.
|
||||
- id: ui-agent-preset
|
||||
name: '@deepseek-ai/dsh-client-ui-agent-preset'
|
||||
|
||||
# Plan control: the composer plan seat over the plan projection + /plan channel.
|
||||
- id: ui-plan
|
||||
name: '@deepseek-ai/dsh-client-ui-plan'
|
||||
@@ -189,3 +197,136 @@
|
||||
|
||||
- id: ui-trajectory
|
||||
name: '@deepseek-ai/dsh-client-ui-trajectory'
|
||||
|
||||
# ── the agent plane moves behind agent presets ─────────────────────────────
|
||||
#
|
||||
# Every row below composes what ONE agent contributes to the host registries:
|
||||
# its tools, its prompt sections, its delegation backends. The base keeps them
|
||||
# for the TUI, which is single-session and composes its agent process-wide; the
|
||||
# Web surface disables them here and lets each session mount a preset instead.
|
||||
#
|
||||
# Disabling rather than deleting is deliberate: the base is shared, and a row
|
||||
# absent from a surface overlay would silently reappear the day someone reorders
|
||||
# the composition.
|
||||
|
||||
# `bash-env` STAYS in the host plane: `apps/cli/src/web.ts` injects it to
|
||||
# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is
|
||||
# the criterion for host-plane ownership — injection resolves before any session
|
||||
# exists, so there is no agent to key by. Behind a preset realm those variables
|
||||
# would never reach the model's shell at all.
|
||||
|
||||
- id: tool-bash
|
||||
disabled: true
|
||||
|
||||
- id: tool-tasks
|
||||
disabled: true
|
||||
|
||||
- id: tasks
|
||||
disabled: true
|
||||
|
||||
- id: tool-fs
|
||||
disabled: true
|
||||
|
||||
- id: tool-fs-search
|
||||
disabled: true
|
||||
|
||||
- id: tool-str-replace-editor
|
||||
disabled: true
|
||||
|
||||
# The `skill` REGISTRY stays in the host plane. It is host+per-scope layered
|
||||
# (the tools-registry shape): deployment-level providers — repository plugins,
|
||||
# a host skill-local row — register into its global layer, while a preset's
|
||||
# `skill-local` registers into that preset's layer, and each agent reads the
|
||||
# merged catalog its scope chain selects. Only the per-agent rows move behind
|
||||
# presets: the base host `skill-local` row is disabled here (presets own local
|
||||
# discovery), and `tool-skill` is what a preset mounts to give its agent the
|
||||
# catalog and loader at all.
|
||||
|
||||
- id: skill-local
|
||||
disabled: true
|
||||
|
||||
- id: tool-skill
|
||||
disabled: true
|
||||
|
||||
# The goal SERVICE, its session driver, and the `/goal` command STAY on the
|
||||
# host plane; only the model-facing tool moves. The Gateway serves the goal
|
||||
# domain as Remote endpoints, and a Remote method picks its receiver Service
|
||||
# from a generated descriptor — it resolves `goals` on the host, so a
|
||||
# per-session realm would answer `service-unavailable` for every browser call.
|
||||
# That is the `bash-env` criterion read from the other side: injection is not
|
||||
# the only host relationship a Service can have. The registry is keyed by
|
||||
# session, so one host instance serves every session exactly as before presets.
|
||||
|
||||
- id: tool-goal
|
||||
disabled: true
|
||||
|
||||
- id: plan-mode
|
||||
disabled: true
|
||||
|
||||
- id: token-meter
|
||||
disabled: true
|
||||
|
||||
- id: compact-basic
|
||||
disabled: true
|
||||
|
||||
- id: command-compact
|
||||
disabled: true
|
||||
|
||||
- id: tool-result-prune
|
||||
disabled: true
|
||||
|
||||
# The subagent registry and its backends STAY in the host plane. `subagents` is
|
||||
# a process singleton with a cross-session query surface (`listChildren`,
|
||||
# `followup`) that the host api-proxy serves to the browser, and a provider
|
||||
# registers under a globally unique name, so a per-session copy would both
|
||||
# starve that host row and collide on the second session. What a preset
|
||||
# chooses is which delegation TOOLS its agent sees, below.
|
||||
|
||||
- id: tool-subagent-control
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent-list-agents
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent-fork
|
||||
disabled: true
|
||||
|
||||
# `tool-subagent-report` is host-plane for the same reason as the registry, not
|
||||
# because a preset may not want it: it registers a CONTINUABLE SETUP on that
|
||||
# singleton rather than a tool this agent calls, and the setup list is not
|
||||
# scope-aware — one copy per mounted preset means every child gets `report`
|
||||
# registered once per live session, which throws on the second.
|
||||
|
||||
- id: workflow-workerthread
|
||||
disabled: true
|
||||
|
||||
- id: tool-workflow
|
||||
disabled: true
|
||||
|
||||
- id: tool-ralph
|
||||
disabled: true
|
||||
|
||||
- id: workspace-context
|
||||
disabled: true
|
||||
|
||||
- id: tool-todo
|
||||
disabled: true
|
||||
|
||||
- id: tool-web
|
||||
disabled: true
|
||||
|
||||
# The preset roster. `config/agent-presets/` ships with the deployment and is
|
||||
# read-only (its entries carry `system` trust);
|
||||
# `$DSH_HOME/.agent-presets` is where a person — or an agent — authors their own, and
|
||||
# carries the same trust as shell access because a preset IS a composition.
|
||||
# `roots` is an assembly fact, not user config: the shipped preset directory
|
||||
# ships beside this file, so AppCLIEntry resolves it and patches it in — the
|
||||
# same treatment `distIndex` gets on the webserver row.
|
||||
- insert:
|
||||
- id: agent-presets
|
||||
name: '@deepseek-ai/dsh-agent-presets'
|
||||
config:
|
||||
default: standard
|
||||
|
||||
@@ -32,13 +32,18 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-hmr": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-deliverables": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-model": "workspace:^",
|
||||
@@ -53,6 +58,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-tool": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-code-runtime-worker": "workspace:^",
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user