feat(profiles): add the SDK application bundle

Introduce @deepseek-ai/dsh-sdk-app as the thin application layer for the built-in sdk profile. The bundle contributes the JSON-RPC server and startup-only profile metadata, while dsh-base continues to own the shared agent, provider, persistence, and tool composition.

Publish ctx.appReady from the launcher only after the Loader tree and launcher-owned setup succeed. The stdio lifetime binding leaves stdin unread until the protocol transport claims it and defers EOF exit 0 until readiness commits, so early protocol frames remain buffered and a racing startup failure remains the nonzero process outcome. Fiber disposal cancels both pending lifecycle listeners.

Register the bundle in the CLI resolver closure, generated configuration catalog, workspace graph, and built-bin smoke. Startup tests prove that base plus sdk-app exposes the SDK server without taking ownership of shared runtime plugins; focused and built-bin regressions cover early input, EOF readiness, and startup-error precedence.
This commit is contained in:
Tianyi Cui
2026-08-23 10:59:00 +08:00
parent 2c9da6eb5b
commit a16822944b
39 changed files with 736 additions and 29 deletions
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/README.md
README.md: eb44939213f62060b6d0be87d113cddf582849ca
README.zh.md: 60e9cf084038fc52cff4dd27f54e2bdb46c61efd
README.md: 89ddef296a0ce4635eda351c9e40720ee2219fdc
README.zh.md: fa63d8189ba87809104a55ca35a76a083372f1d8
+1 -1
View File
@@ -36,7 +36,7 @@ The tree composes over an empty root:
- then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`
- then `--patch` overlays
Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins.
Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins.
Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it.
+1 -1
View File
@@ -38,7 +38,7 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以
- profile 自身的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`
- `--patch` 指定的覆盖层
`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base``@deepseek-ai/dsh-web-app``@deepseek-ai/dsh-headless`),再从 profile 自身的 `node_modules` 解析;pnpm 会将树外插件安装到该目录。
`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base``@deepseek-ai/dsh-web-app``@deepseek-ai/dsh-headless``@deepseek-ai/dsh-sdk-app`),再从 profile 自身的 `node_modules` 解析;pnpm 会将树外插件安装到该目录。
使用 `--dump-default-config``--dump-config` 可在不启动的情况下检查组合后的配置树。
+1
View File
@@ -55,6 +55,7 @@
"@deepseek-ai/dsh-pwsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-sdk-app": "workspace:^",
"@deepseek-ai/dsh-time-context": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-filesystem": "workspace:^",
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/reference/README.md
README.md: bc32f2a2f97c15e20686f4368d6bed2622e78b2e
README.zh.md: 39fb2e77242152ac4a591804f4319968fcdc9bb2
README.md: 29e5937547b2175a003f036a1a1d70e124d19ad7
README.zh.md: 4202addbbc97383ef4fe9ae4c1889304a81e49ca
+3 -2
View File
@@ -8,9 +8,9 @@ This reference defines the profile, web-alias, plugin-management, and config-dum
`dsh --profile <name>` boots the profile at `$DSH_HOME/profiles/<name>`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch <path>` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. `dsh.profile.patchReload` selects `live` patch-file watching or `startup` one-time loading; omission defaults a custom profile to `live`. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. A bare plugin `name` in any patch row resolves through the profile directory's Node parent-walk, which reaches the maintained installation fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch).
Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. A bare plugin `name` in any patch row resolves through the profile directory's Node parent-walk, which reaches the maintained installation fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch).
The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app with live patches; `headless`: base + headless with startup-only patches). Any other missing profile fails loud with a hint to run `dsh plugin --profile <name> add <package>`.
The `web`, `headless`, and `sdk` profiles auto-initialize from shipped templates on first use (`web`: base + web-app with live patches; `headless`: base + headless with startup-only patches; `sdk`: base + sdk-app with startup-only patches). Any other missing profile fails loud with a hint to run `dsh plugin --profile <name> add <package>`.
### App arguments
@@ -26,6 +26,7 @@ The shipped apps own these command lines:
|---|---|
| `web` | `--host`, `--port`, repeatable `--trusted-host`, `--no-open` |
| `headless` | the task text, as the positional argument |
| `sdk` | no options; stdio carries the JSON-RPC protocol |
A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. An invocation with no task is a usage error from that app. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port.
+3 -2
View File
@@ -8,9 +8,9 @@
`dsh --profile <name>` 启动位于 `$DSH_HOME/profiles/<name>` 的 profile。生效配置树以空根节点为起点,依次叠加 profile manifest(元数据清单)的 `dsh.profile.bundles` 列表中指定的各组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(这是各 profile 共享的机器本地偏好,因此优先于逐 profile 配置层),以及按 argv 顺序指定的各个 `--patch <path>` 覆盖层。对同一配置行,后应用的层优先。patch 会替换目标行的整个 `config` 值,而不是深度合并其中的键;patch 也可以插入新行。`dsh.profile.patchReload` 可选择 `live` patch 文件监视或 `startup` 单次加载;自定义 profile 省略该值时默认使用 `live`。配置解析、schema 校验、模块解析或插件启动失败时,系统会报告错误并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。
组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base``@deepseek-ai/dsh-web-app``@deepseek-ai/dsh-headless`)始终来自当前运行的 `dsh` 所属的安装;树外组合包则来自 profile 中由 pnpm 管理的 `node_modules`。patch 行中的裸插件 `name` 会从 profile 目录开始,按照 Node 的模块解析规则逐级向父目录查找,直至由 dsh 维护的安装后备目录 `$DSH_HOME/profiles/node_modules`。该目录为 dsh 安装中的应用和组合包所依赖的每个包各维护一个符号链接,并在每次启动时修复这些链接。
组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base``@deepseek-ai/dsh-web-app``@deepseek-ai/dsh-headless``@deepseek-ai/dsh-sdk-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包则来自 profile 中由 pnpm 管理的 `node_modules`。patch 行中的裸插件 `name` 会从 profile 目录开始,按照 Node 的模块解析规则逐级向父目录查找,直至由 dsh 维护的安装后备目录 `$DSH_HOME/profiles/node_modules`。该目录为 dsh 安装中的应用和组合包所依赖的每个包各维护一个符号链接,并在每次启动时修复这些链接。
`web``headless` profile 首次使用时会从随附模板自动初始化(`web`base + web-app,实时应用 patch`headless`base + headless,只在启动时应用 patch)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile <name> add <package>`
`web``headless``sdk` profile 首次使用时会从随附模板自动初始化(`web`base + web-app,实时应用 patch`headless`base + headless,只在启动时应用 patch`sdk`base + sdk-app,只在启动时应用 patch)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile <name> add <package>`
### 应用参数
@@ -26,6 +26,7 @@
|---|---|
| `web` | `--host``--port`、可重复的 `--trusted-host``--no-open` |
| `headless` | 任务文本,作为位置参数 |
| `sdk` | 无选项;stdio 携带 JSON-RPC 协议 |
一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对会话执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。
+32 -1
View File
@@ -35,11 +35,35 @@ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url))
import { DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
import { provideCmdline, type AppReady } from '@deepseek-ai/dsh-cmdline'
import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
const NAME = 'dsh'
/** Launcher-owned readiness signal committed only after boot and host setup succeed. */
function createAppReady(): { service: AppReady; commit(): void } {
let ready = false
const listeners = new Set<() => void>()
return {
service: {
onReady(listener) {
if (ready) {
listener()
return () => {}
}
listeners.add(listener)
return () => { listeners.delete(listener) }
},
},
commit() {
if (ready) return
ready = true
for (const listener of [...listeners]) listener()
listeners.clear()
},
}
}
/**
* The home-level user patch layer (`$DSH_HOME/cordis.patch.yml`), applied
* over every profile's own layer. Resolved per call, not at module load:
@@ -207,6 +231,7 @@ function suppressShutdownError(ctx: Context, signal: AbortSignal, error: unknown
export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> {
const composed = composeProfile(options.profile, options.patchFiles)
const app: { current?: Context } = {}
const appReady = createAppReady()
const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() })
const signalShutdown = new AbortController()
const interrupt = (code: number): void => {
@@ -255,6 +280,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
provideCmdline(hostCtx, {
args: options.args,
exit: code => void shutdown.shutdown(code),
ready: appReady.service,
})
})
app.current = ctx
@@ -295,5 +321,10 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
suppressShutdownError(ctx, signalShutdown.signal, error)
}
}
if (!signalShutdown.signal.aborted
&& ctx.fiber.state === FiberState.ACTIVE
&& ctx.get('loader') !== undefined) {
appReady.commit()
}
return { ctx, shutdown }
}
+88
View File
@@ -1,6 +1,7 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createInterface } from 'node:readline'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
import { execa } from 'execa'
@@ -356,6 +357,14 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
expect(headlessHelp.stderr).toBe('')
expect(headlessHelp.stdout).toContain('Usage: dsh --profile headless')
const sdkHelp = await runBuiltBin(['--profile', 'sdk', '--help'], {
DSH_HOME: home,
DSH_TELEMETRY_DISABLED: '1',
})
expect(sdkHelp.code).toBe(0)
expect(sdkHelp.stderr).toBe('')
expect(sdkHelp.stdout).toContain('Usage: dsh --profile sdk')
const missingTask = await runBuiltBin(['--profile', 'headless'], {
DSH_HOME: home,
DSH_TELEMETRY_DISABLED: '1',
@@ -367,6 +376,85 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
}
}, 30_000)
it('reports SDK startup failure when stdin reaches EOF first', async () => {
const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-startup-failure-'))
const patch = join(home, 'broken-sdk.cordis.yml')
writeFileSync(patch, [
'- insert:',
' - id: missing-sdk-startup-plugin',
' name: "@deepseek-ai/dsh-missing-sdk-startup-plugin"',
'',
].join('\n'))
try {
const result = await runBuiltBin(['--profile', 'sdk', '--patch', patch], {
DSH_HOME: home,
DSH_TELEMETRY_DISABLED: '1',
DEEPSEEK_API_KEY: 'built-sdk-startup-failure-no-call',
}, home)
expect(result.code).toBe(1)
expect(result.stdout).toBe('')
expect(result.stderr).toContain('plugin tree failed to load')
expect(result.stderr).toContain('@deepseek-ai/dsh-missing-sdk-startup-plugin')
} finally {
rmSync(home, { recursive: true, force: true })
}
}, 30_000)
it('serves the SDK protocol through the sdk profile and exits after shutdown', async () => {
const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-'))
const child = execa(process.execPath, [dshBin, '--profile', 'sdk'], {
cwd: home,
reject: false,
timeout: 25_000,
killSignal: 'SIGKILL',
env: {
...process.env,
DSH_HOME: home,
DSH_TELEMETRY_DISABLED: '1',
DEEPSEEK_API_KEY: 'built-sdk-profile-no-call',
},
extendEnv: false,
})
const stdoutLines = createInterface({ input: child.stdout, crlfDelay: Infinity })[Symbol.asyncIterator]()
let stderr = ''
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
const response = async (id: number): Promise<Record<string, unknown>> => {
for (;;) {
const line = await stdoutLines.next()
if (line.done) throw new Error(`SDK profile stdout closed before response ${String(id)}; stderr=${stderr}`)
let value: Record<string, unknown>
try {
value = JSON.parse(line.value) as Record<string, unknown>
} catch {
throw new Error(`SDK profile wrote non-JSON stdout: ${line.value}`)
}
if (value.id === id) return value
}
}
try {
child.stdin.write(`${JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { cwd: home, provider: 'deepseek-official', model: 'deepseek-v4-flash' },
})}\n`)
expect(await response(1)).toMatchObject({
jsonrpc: '2.0',
id: 1,
result: { serverInfo: { name: 'deepseek-harness-sdk-runtime' } },
})
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'shutdown' })}\n`)
expect(await response(2)).toEqual({ jsonrpc: '2.0', id: 2, result: {} })
const result = await child
expect(result.exitCode, `signal=${String(result.signal)}; stderr=${stderr}`).toBe(0)
expect(stderr).toBe('')
} finally {
child.kill('SIGKILL')
await child
rmSync(home, { recursive: true, force: true })
}
}, 30_000)
it('runs the headless profile through its app-owned task positional', async () => {
const apiKey = 'built-dsh-headless-key'
const server = await startMockLlmServer({
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: b2cb3faea338e0fe7d796aea626da4e91539b48b
config-catalog.zh.md: c1e33e3724a78749e6a90202d642564c8b3d106d
config-catalog.md: 8e93605efd023c30f90108b2c46138984e2fbcd6
config-catalog.zh.md: 4137a18d5218e1512809ead16769233a26f476cc
+1
View File
@@ -3333,6 +3333,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts))
- `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts))
- `@deepseek-ai/dsh-schedule` — requires `agents` · `sessions` · `tools` · `sessionPersistence` ([`packages/schedule/schedule/src/index.ts`](../packages/schedule/schedule/src/index.ts))
- `@deepseek-ai/dsh-sdk-app` — requires `cmdlineArgs` ([`packages/bundle/sdk-app/src/index.ts`](../packages/bundle/sdk-app/src/index.ts))
- `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts))
- `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts))
- `@deepseek-ai/dsh-session-log-export` — requires `commands` ([`packages/session-query/session-log-export/src/index.ts`](../packages/session-query/session-log-export/src/index.ts))
+1
View File
@@ -3335,6 +3335,7 @@ export interface Config {
- `@deepseek-ai/dsh-llm`[`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)
- `@deepseek-ai/dsh-lsp`[`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)
- `@deepseek-ai/dsh-schedule` — 需要 `agents` · `sessions` · `tools` · `sessionPersistence`[`packages/schedule/schedule/src/index.ts`](../packages/schedule/schedule/src/index.ts)
- `@deepseek-ai/dsh-sdk-app` — 需要 `cmdlineArgs`[`packages/bundle/sdk-app/src/index.ts`](../packages/bundle/sdk-app/src/index.ts)
- `@deepseek-ai/dsh-session`[`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)
- `@deepseek-ai/dsh-session-checkpoint-policy` — 需要 `llm` · `sessionPersistence` · `sessions` · `tools`[`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts)
- `@deepseek-ai/dsh-session-log-export` — 需要 `commands`[`packages/session-query/session-log-export/src/index.ts`](../packages/session-query/session-log-export/src/index.ts)
+5
View File
@@ -676,6 +676,11 @@
"@deepseek-ai/dsh-code-runtime-worker-thread"
]
},
"packages/bundle/sdk-app": {
"ignoreDependencies": [
"@deepseek-ai/dsh-sdk-jsonrpc-server"
]
},
"packages/bundle/web-app": {
"ignoreDependencies": [
"@deepseek-ai/.+"
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
README.md: 142f5145d34bf461232e7083fdbebf3f242e651f
README.zh.md: 3139edd4e77ea50ec129c36df15ee5aaf8df6d64
README.md: 081ce26727790e79d5bd1a23f396b2b35de4b78e
README.zh.md: 3c8e5186acfb164671ad21a65342032dde226c49
+1 -1
View File
@@ -35,7 +35,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/home-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 `patchReload: live | startup` — and the user's own `cordis.patch.yml`. `live` watches the profile and home-level patch files after boot; `startup` applies every layer once. A missing value keeps the historical `live` default for custom profiles. 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` auto-initializes `web` with live reload and `headless` with startup-only patches; other names fail loud until `initProfile` creates them through `dsh plugin`. `loadProfile` normalizes an exact installation-owned bundle tuple and a missing reload choice to its shipped template while preserving every explicit reload choice and every other manifest field; any extra, missing, or reordered bundle makes the list user-owned and leaves it unchanged.
A profile is a directory under `$DSH_HOME/profiles/<name>` (the Harness home resolves through [`resolveDshHome`](../../util/home-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 `patchReload: live | startup` — and the user's own `cordis.patch.yml`. `live` watches the profile and home-level patch files after boot; `startup` applies every layer once. A missing value keeps the historical `live` default for custom profiles. 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` auto-initializes `web` with live reload and `headless`/`sdk` with startup-only patches; other names fail loud until `initProfile` creates them through `dsh plugin`. `loadProfile` normalizes an exact installation-owned bundle tuple and a missing reload choice to its shipped template while preserving every explicit reload choice and every other manifest field; any extra, missing, or reordered bundle makes the list user-owned and leaves it unchanged.
User-level machine-local preferences also live in the Harness home:
+1 -1
View File
@@ -35,7 +35,7 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面
## Profiles
profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.zh.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表和 `patchReload: live | startup`)和用户自己的 `cordis.patch.yml``live` 会在启动后监视 profile 与 home 级 patch 文件;`startup` 只应用每层一次。缺失值为自定义 profile 保留历史 `live` 默认值。组合包是在 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`,以仅启动时 patch 初始化 `headless`;其他名称在通过 `dsh plugin``initProfile` 创建前都会明确报错。`loadProfile` 会把安装自有的精确组合包元组和缺失的重载选择规范化为随附模板,同时保留每个显式重载选择和 manifest 中其他所有字段;组合包一旦有任何额外、缺失或重排,列表就归用户所有并保持不变。
profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.zh.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表和 `patchReload: live | startup`)和用户自己的 `cordis.patch.yml``live` 会在启动后监视 profile 与 home 级 patch 文件;`startup` 只应用每层一次。缺失值为自定义 profile 保留历史 `live` 默认值。组合包是在 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`,以仅启动时 patch 初始化 `headless``sdk`;其他名称在通过 `dsh plugin``initProfile` 创建前都会明确报错。`loadProfile` 会把安装自有的精确组合包元组和缺失的重载选择规范化为随附模板,同时保留每个显式重载选择和 manifest 中其他所有字段;组合包一旦有任何额外、缺失或重排,列表就归用户所有并保持不变。
用户级的机器本地偏好同样位于 harness home 中:
+4
View File
@@ -135,6 +135,10 @@ export const PROFILE_TEMPLATES: Record<string, ProfileTemplate> = {
bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'],
patchReload: 'startup',
},
sdk: {
bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-sdk-app'],
patchReload: 'startup',
},
}
/** Installation-owned bundle tuples normalized to the shipped template. */
@@ -158,6 +158,10 @@ describe('loadProfile', () => {
expect(PROFILE_TEMPLATES.web?.bundles).toContain('@deepseek-ai/dsh-base')
expect(PROFILE_TEMPLATES.web?.patchReload).toBe('live')
expect(PROFILE_TEMPLATES.headless?.patchReload).toBe('startup')
expect(PROFILE_TEMPLATES.sdk).toEqual({
bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-sdk-app'],
patchReload: 'startup',
})
try {
loadProfile('t', 'web', anchor, home)
} catch {
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/boot/cmdline/README.md
README.md: 33125014539e801dbd2952a3b4513cafc80bdcee
README.zh.md: 7ef49a1027d3c17817c9171e1166ed6feecd8559
README.md: 4a0244679e0451a196ff6bb55a7eaea9e53775d9
README.zh.md: 77063ccd3e3107ba9ab01b1a06eae49f54c1006a
+4
View File
@@ -10,9 +10,12 @@ A launcher calls `provideCmdline(ctx, host)` before any tree entry mounts, which
- `ctx.cmdlineArgs` — the invocation's inner arguments. `get()` is the whole interface, and it returns a snapshot: `dsh --profile tui --resume abc` yields `['--resume', 'abc']`.
- `ctx.appExit` — a bounded process-exit request, wired to the launcher's shutdown controller.
- `ctx.appReady` — the launcher's successful-startup signal. It commits only after the Loader tree and launcher-owned setup succeed; failed or externally terminated startup never calls pending listeners.
An embedding host with no command line provides an empty list; that is the honest answer, not a missing value.
`exitOnStdinEnd(ctx, label)` binds a successfully accepted stdio application's EOF to `ctx.appExit(0)` after `ctx.appReady` commits. It never reads or resumes stdin, so the protocol transport receives bytes buffered before it mounts. A startup rejection wins over a racing EOF, an already-ended stream still requests shutdown after successful startup, and the calling plugin's fiber removes both pending listeners. An app calls it inside the same command action that publishes its startup service, so help and rejected arguments leave the transport and EOF lifecycle unmounted.
## Ordinary providers and injected config
Any app plugin may inject `cmdlineArgs`, parse it, and publish an ordinary app-owned service. `parseCmdline(ctx, program)` is only a commander adapter; the program's own action owns validation and the published service:
@@ -71,3 +74,4 @@ None; this package neither assembles nor sends a provider request.
- **Launcher flags must precede app arguments.** The split is positional: the first token the launcher does not recognize starts the inner arguments, so `--patch` placed after an app flag belongs to the app. The launcher's parser consumes one `--`, so an app argument that must survive as a literal `--` needs `-- --`.
- **An app-owned service has no statically declared provider.** Consumer rows name it through ordinary injection; a bundle that omits its provider fails at settlement with pending entries naming the service rather than at load.
- **A user patch that replaces a row's whole `config` drops its expressions.** A flag beats the value written beside it, not a literal a user wrote in place of the expression; keeping the expression is what keeps the flag winning.
- **EOF means successful application shutdown.** `exitOnStdinEnd` is for a stdio protocol process whose client owns stdin; an interactive application with unrelated stdin semantics does not call it.
+4
View File
@@ -10,9 +10,12 @@ dsh 启动器交给它所引导应用的那条命令行。启动器只解析属
- `ctx.cmdlineArgs`:本次调用的内层参数。`get()` 就是它的全部接口,返回一份快照:`dsh --profile tui --resume abc` 得到 `['--resume', 'abc']`
- `ctx.appExit`:一个有边界的进程退出请求,接到启动器的关停控制器上。
- `ctx.appReady`:启动器的成功启动信号。只有 Loader 树和启动器自身的设置都成功后才会提交;启动失败或被外部终止时,待处理 listener 永远不会被调用。
没有命令行的嵌入宿主提供空列表;这是诚实的答案,而不是缺失的值。
`exitOnStdinEnd(ctx, label)` 会在 `ctx.appReady` 提交后,把已成功接受的 stdio 应用 EOF 接到 `ctx.appExit(0)`。它从不读取或恢复 stdin,因此协议 transport 会收到挂载前缓冲的字节。启动失败与 EOF 竞争时由启动失败决定结果;绑定前已经结束的 stream 仍会在启动成功后请求关闭;调用插件的 fiber 会移除两个待处理 listener。应用在发布启动服务的同一个命令 action 中调用它,因此 help 与被拒参数不会挂载 transport 或 EOF 生命周期。
## 普通提供方与注入配置
任何应用插件都可以注入 `cmdlineArgs`、解析它,再发布一个普通的应用自有服务。`parseCmdline(ctx, program)` 只适配 commander;校验与发布的服务都归 program 自己的 action 持有:
@@ -71,3 +74,4 @@ Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活
- **启动器的 flag 必须写在应用参数之前**:切分按位置进行,启动器不认识的第一个 token 就是内层参数的起点,因此写在某个应用 flag 之后的 `--patch` 属于应用。启动器的解析器会消耗掉一个 `--`,因此必须以字面量 `--` 存活到应用的参数需要写成 `-- --`。
- **应用自有服务没有静态声明的提供方**:消费行通过普通注入点名它;缺少提供方的组合包会在结算时失败,由待处理条目点名该服务,而不是在加载时失败。
- **用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉**:flag 胜过的是表达式旁写着的那个值,而不是用户用字面量替换掉表达式之后的结果;保留表达式才能保留 flag 的优先级。
- **EOF 表示应用成功关闭**:`exitOnStdinEnd` 适用于由客户端持有 stdin 的 stdio 协议进程;stdin 另有交互语义的应用不会调用它。
+73 -6
View File
@@ -41,12 +41,25 @@ export interface AppExit {
(code: number): void
}
/** Successful application-startup signal owned by the launcher. */
export interface AppReady {
/**
* Run a listener once successful startup is committed. A failed or
* externally terminated startup never calls it.
* @param listener - work that may begin only after successful startup.
* @returns a disposer that cancels a pending listener.
*/
onReady(listener: () => void): () => void
}
declare module '@deepseek-ai/cordis' {
interface Context {
/** The invocation's inner arguments; provided by a launcher before the tree mounts. */
cmdlineArgs?: CmdlineArgs
/** Bounded process-exit request; provided by a launcher before the tree mounts. */
appExit?: AppExit
/** Successful startup signal; provided by a launcher before the tree mounts. */
appReady?: AppReady
}
}
@@ -56,27 +69,81 @@ export interface CmdlineHost {
args: readonly string[]
/** Bounded process-exit request. */
exit: AppExit
/** Successful startup signal for lifecycle work that must not mask boot failure. */
ready?: AppReady
}
/**
* Provide the command line and the exit request on a host context before any
* tree entry mounts. Both are launcher facts, not config: an embedding host
* with no command line provides an empty argument list.
* Provide launcher facts on a host context before any tree entry mounts: the
* command line, bounded exit request, and optional successful-startup signal.
* An embedding host with no command line provides an empty argument list; a
* host that mounts a stdio application also provides readiness.
* @param ctx - the host context the tree will mount under.
* @param host - the invocation's arguments and its exit request.
* @param host - the invocation's arguments, exit request, and optional readiness signal.
*/
export function provideCmdline(ctx: Context, host: CmdlineHost): void {
const snapshot: readonly string[] = Object.freeze([...host.args])
ctx.provide('cmdlineArgs', { get: () => snapshot })
ctx.provide('appExit', host.exit)
if (host.ready !== undefined) ctx.provide('appReady', host.ready)
}
/** The process streams commander output is written to; production writes to the process. */
export const internals: { stdout: { write(chunk: string): unknown }; stderr: { write(chunk: string): unknown } } = {
/** Process stdin operations used to bind a stdio application's lifetime. */
export interface AppStdin {
/** Whether EOF arrived before the application bound its listener. */
readonly readableEnded: boolean
/** Subscribe once to stdin EOF. */
once(event: 'end', listener: () => void): unknown
/** Remove a previously installed stdin EOF listener. */
off(event: 'end', listener: () => void): unknown
}
/** Process streams used by app command lines and stdio lifetime binding; tests substitute them. */
export const internals: {
stdin: AppStdin
stdout: { write(chunk: string): unknown }
stderr: { write(chunk: string): unknown }
} = {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
}
/**
* Make stdin EOF request the launcher's bounded successful shutdown after
* {@link AppReady} commits. A startup rejection therefore remains the process
* outcome when it races EOF. The caller invokes this only after its command
* action accepts the invocation, so help and usage failures start no transport
* lifecycle. This listener does not read or resume stdin: the protocol
* transport owns input and receives bytes buffered before it mounts. Disposal
* removes the EOF and readiness listeners.
* @param ctx - app plugin context carrying the launcher's exit request.
* @param label - effect label naming the owning application.
*/
export function exitOnStdinEnd(ctx: Context, label: string): void {
const exit = ctx.get('appExit')
const ready = ctx.get('appReady')
if (exit === undefined || ready === undefined) {
throw new Error('stdio app: the launcher must provide ctx.appExit and ctx.appReady before the tree mounts')
}
const stdin = internals.stdin
let active = true
let ended = false
let cancelReady = (): void => {}
const onEnd = (): void => {
if (!active || ended) return
ended = true
cancelReady = ready.onReady(() => { exit(0) })
}
ctx.effect(() => () => {
active = false
cancelReady()
stdin.off('end', onEnd)
}, label)
stdin.once('end', onEnd)
if (stdin.readableEnded) queueMicrotask(onEnd)
}
/**
* Parse the launcher's immutable argument snapshot with an app's commander
* program. Commander runs the program's own synchronous action handler on a
+136 -2
View File
@@ -5,16 +5,18 @@
*/
import { mkdtempSync, writeFileSync } from 'node:fs'
import { EventEmitter } from 'node:events'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PassThrough } from 'node:stream'
import { pathToFileURL } from 'node:url'
import { Command } from 'commander'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import { afterEach, describe, expect, it } from 'vitest'
import { internals, parseCmdline, provideCmdline } from '../src/index.ts'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { exitOnStdinEnd, internals, parseCmdline, provideCmdline, type AppReady } from '../src/index.ts'
/** Every value one boot of the fixture tree observed. */
interface Observed {
@@ -32,12 +34,46 @@ interface Fixture {
const disposers: (() => Promise<void>)[] = []
const readyApp: AppReady = {
onReady(listener) {
listener()
return () => {}
},
}
function controlledAppReady(): { service: AppReady; commit(): void } {
const listeners = new Set<() => void>()
return {
service: {
onReady(listener) {
listeners.add(listener)
return () => { listeners.delete(listener) }
},
},
commit() {
for (const listener of [...listeners]) listener()
listeners.clear()
},
}
}
afterEach(async () => {
for (const dispose of disposers.splice(0)) await dispose()
internals.stdin = process.stdin
internals.stdout = process.stdout
internals.stderr = process.stderr
})
/** In-memory stdin whose end edge and ended-before-bind state are controllable. */
class TestStdin extends EventEmitter {
readableEnded = false
end(): void {
this.readableEnded = true
this.emit('end')
}
}
/** The fixture app's flag family: one `--port` its rows read from the service. */
function demoCommand(): Command {
return new Command().name('demo').exitOverride().option('--port <port>', 'listen port')
@@ -230,3 +266,101 @@ describe('provideCmdline', () => {
expect(Object.isFrozen(ctx.cmdlineArgs?.get())).toBe(true)
})
})
describe('exitOnStdinEnd', () => {
it('requests bounded exit on EOF and removes the listener on disposal', async () => {
const ctx = new Context()
const stdin = new TestStdin()
const exits: number[] = []
internals.stdin = stdin
provideCmdline(ctx, { args: [], exit: code => void exits.push(code), ready: readyApp })
exitOnStdinEnd(ctx, 'test.stdin')
stdin.end()
expect(exits).toEqual([0])
await ctx.fiber.dispose()
stdin.emit('end')
expect(exits).toEqual([0])
})
it('requests exit after binding to stdin that has already ended', async () => {
const ctx = new Context()
const stdin = new TestStdin()
const exits: number[] = []
stdin.readableEnded = true
internals.stdin = stdin
provideCmdline(ctx, { args: [], exit: code => void exits.push(code), ready: readyApp })
exitOnStdinEnd(ctx, 'test.stdin')
stdin.end()
await Promise.resolve()
expect(exits).toEqual([0])
})
it('cancels an already-ended stream before its queued EOF handler runs', async () => {
const ctx = new Context()
const stdin = new TestStdin()
const exits: number[] = []
let queued: (() => void) | undefined
const queue = vi.spyOn(globalThis, 'queueMicrotask').mockImplementation((listener) => { queued = listener })
stdin.readableEnded = true
internals.stdin = stdin
try {
provideCmdline(ctx, { args: [], exit: code => void exits.push(code), ready: readyApp })
exitOnStdinEnd(ctx, 'test.stdin')
await ctx.fiber.dispose()
queued?.()
expect(exits).toEqual([])
} finally {
queue.mockRestore()
}
})
it('leaves protocol bytes buffered until the transport claims stdin', async () => {
const ctx = new Context()
const stdin = new PassThrough()
const exits: number[] = []
internals.stdin = stdin
provideCmdline(ctx, { args: [], exit: code => void exits.push(code), ready: readyApp })
exitOnStdinEnd(ctx, 'test.stdin')
const frame = '{"jsonrpc":"2.0","id":1,"method":"initialize"}\n'
stdin.write(frame)
expect(stdin.readableFlowing).not.toBe(true)
let received = ''
stdin.on('data', (chunk: Buffer) => { received += chunk.toString('utf8') })
const ended = new Promise<void>((resolve) => { stdin.once('end', resolve) })
stdin.end()
await ended
expect(received).toBe(frame)
expect(exits).toEqual([0])
await ctx.fiber.dispose()
})
it('waits for the launcher to commit successful startup after EOF', async () => {
const ctx = new Context()
const stdin = new TestStdin()
const exits: number[] = []
const ready = controlledAppReady()
internals.stdin = stdin
provideCmdline(ctx, { args: [], exit: code => void exits.push(code), ready: ready.service })
exitOnStdinEnd(ctx, 'test.stdin')
stdin.end()
expect(exits).toEqual([])
ready.commit()
expect(exits).toEqual([0])
await ctx.fiber.dispose()
})
it('fails loud without a launcher exit request', () => {
internals.stdin = new TestStdin()
expect(() => { exitOnStdinEnd(new Context(), 'test.stdin') }).toThrow('launcher must provide ctx.appExit and ctx.appReady')
})
it('fails loud without launcher startup readiness', () => {
const ctx = new Context()
internals.stdin = new TestStdin()
provideCmdline(ctx, { args: [], exit: () => {} })
expect(() => { exitOnStdinEnd(ctx, 'test.stdin') }).toThrow('launcher must provide ctx.appExit and ctx.appReady')
})
})
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bundle/README.md
README.md: 4d7a064939ae04f25737b324ec35332b7b944f80
README.zh.md: 9bee067d77124cfcac1ecae92706b2429dd38ee0
README.md: e0edabf2d2777eafca0773c8472f011f787e5a15
README.zh.md: 56e1c4131822a59ca8135c6bba062fe634346578
+1
View File
@@ -11,5 +11,6 @@ The manifest declaration, not this directory, defines Bundle identity. Domain pa
| [`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) | Direct one-shot task mode over base, with no Host or Web layer | mounts `headless-runner` |
| [`sdk-app/`](sdk-app/README.md) | SDK stdio JSON-RPC application over base | mounts the SDK server |
In-box bundles resolve from the dsh installation; out-of-tree bundles install into a profile through `dsh plugin --profile <name> add <package>`.
+1
View File
@@ -11,5 +11,6 @@ Bundle 身份由 manifest 声明决定,而不是由本目录决定。领域包
| [`base/`](base/README.zh.md) | 每个 profile 最先应用的共享 dsh 核心 | —(仅 patch |
| [`web-app/`](web-app/README.zh.md) | 浏览器表层:web patch 层 + 运行时粘合插件 | 挂载多条配置行 |
| [`headless/`](headless/README.zh.md) | 直接运行在 base 之上的一次性任务模式,不含 Host 或 Web 层 | 挂载 `headless-runner` |
| [`sdk-app/`](sdk-app/README.zh.md) | 运行在 base 之上的 SDK stdio JSON-RPC 应用 | 挂载 SDK server |
内置组合包从 dsh 安装目录解析;树外(out-of-tree)组合包通过 `dsh plugin --profile <name> add <package>` 安装进 profile。
+6
View 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/bundle/sdk-app/README.md
README.md: d639007e419904e9e9d7a052d291da8ca6d38a3c
README.zh.md: a3478fe8548fa8ec411167534e26d75121710c9a
+31
View File
@@ -0,0 +1,31 @@
# `@deepseek-ai/dsh-sdk-app`
English | [中文](README.zh.md)
The SDK stdio application as a `dsh` profile bundle over [`dsh-base`](../base/README.md). Its patch sets the coding-agent persona, disables module HMR, mounts an app-owned zero-option command provider, and starts [`dsh-sdk-jsonrpc-server`](../../sdk/server/README.md) only after that provider accepts the invocation. `dsh --profile sdk --help` therefore writes help and exits without claiming stdin or stdout.
The startup provider binds stdin EOF to the launcher's bounded successful shutdown. SDK protocol `shutdown`, SIGINT, and SIGTERM retain their owning server or launcher paths; disposal drains the root profile tree and persistence. Stdout is reserved for newline-delimited JSON-RPC frames. A deployment selects a different complete composition through profile bundles and patch files, not another app bin.
`DSH_MAX_TOKENS_AS_SUCCESS` retains the SDK deployment mapping: unset or JSON `true` reports token-limited subagent completion as accepted, while JSON `false` reports it as an error. Provider/model and workspace cwd arrive through the SDK initialization request; the base profile owns adapters, tools, persistence, policy, settings, and credentials.
## Model Experience
### SDK coding-agent persona
#### What the model sees
The profile supplies `You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.` before the base tool and context contributions. The exact SDK initialization route and session cwd resolve the placeholders.
#### Token effect
One short stable persona plus the data-dependent base prompt sections and selected tool schemas.
#### KV Cache effect
Stable for a fixed profile, provider, model, and tool roster. Profile changes take effect on the next process because the shipped SDK profile uses startup-only patches.
## Known Limitations and Deferred Work
- **A profile can omit the SDK server** — a custom profile selected by the TypeScript client must retain this bundle or another `dsh-sdk-jsonrpc-server` row; client initialization fails when no peer answers.
- **User plugins can violate stdout purity** — profile and per-launch patches are trusted application composition. The shipped bundle writes no non-protocol stdout, but it cannot contain an arbitrary inserted plugin.
- **Configuration changes require restart** — the shipped `sdk` profile uses `patchReload: startup` so one stdio connection never observes a replacement server or Agent dependency.
+31
View File
@@ -0,0 +1,31 @@
# `@deepseek-ai/dsh-sdk-app`
[English](README.md) | 中文
以 [`dsh-base`](../base/README.zh.md) 为基础的 SDK stdio 应用 `dsh` profile 组合包。其 patch 设置 coding agent(编程智能体)persona、禁用模块 HMR(热模块替换)、挂载应用自有的零选项命令提供方,并且只在该提供方接受调用后启动 [`dsh-sdk-jsonrpc-server`](../../sdk/server/README.zh.md)。因此,`dsh --profile sdk --help` 会写出 help 并退出,不会占用 stdin 或 stdout。
启动提供方把 stdin EOF 接到启动器的有界成功关闭流程。SDK 协议 `shutdown`、SIGINT 与 SIGTERM 继续使用各自所属的 server 或启动器路径;dispose(资源释放)会排空根 profile 配置树与持久化。stdout 专用于按换行分隔的 JSON-RPC 帧。部署通过 profile 组合包与 patch 文件选择另一套完整组合,而不是使用另一个应用 bin。
`DSH_MAX_TOKENS_AS_SUCCESS` 保留 SDK 部署映射:未设置或 JSON `true` 把 token 达限的 subagent 完成报告为已接受,JSON `false` 则报告为错误。模型提供方/模型与工作区 cwd 通过 SDK 初始化请求传入;base profile 拥有适配器、工具、持久化、策略、settings 与 credentials。
## 模型体验
### SDK coding agent persona
#### 模型看到什么
profile 会在 base 工具与上下文贡献之前提供 `You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.`。确切的 SDK 初始化路由与会话 cwd 会解析其中的占位符。
#### Token 影响
一段简短稳定的 persona,加上随数据变化的 base 提示词段落与所选工具 schema。
#### KV Cache 影响
对固定 profile、提供方、模型与工具清单保持稳定。由于随附 SDK profile 使用仅启动时 patchprofile 变化会在下一个进程生效。
## 已知限制与延期工作
- **profile 可以省略 SDK server**TypeScript client 选择的自定义 profile 必须保留本组合包或另一个 `dsh-sdk-jsonrpc-server` 配置项;没有 peer 响应时,client 初始化会失败。
- **用户插件可以破坏 stdout 纯净性**profile 与逐次启动 patch 属于受信任应用组合。随附组合包不会向 stdout 写入非协议内容,但无法约束任意插入插件。
- **配置变化需要重启**:随附 `sdk` profile 使用 `patchReload: startup`,因此一个 stdio 连接不会观察到 server 或 Agent 依赖被替换。
+19
View File
@@ -0,0 +1,19 @@
# The SDK application over dsh-base. Stdout belongs exclusively to JSON-RPC.
- id: system-prompt
config:
persona: >-
You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
- id: hmr
disabled: true
- insert:
- id: sdk-app-startup
name: '@deepseek-ai/dsh-sdk-app'
- id: sdk-jsonrpc-server
name: '@deepseek-ai/dsh-sdk-jsonrpc-server'
inject: [sdkAppStartup]
config:
maxTokensAsSuccess: !!js "process.env.DSH_MAX_TOKENS_AS_SUCCESS === undefined ? true : JSON.parse(process.env.DSH_MAX_TOKENS_AS_SUCCESS)"
+55
View File
@@ -0,0 +1,55 @@
{
"name": "@deepseek-ai/dsh-sdk-app",
"description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base",
"version": "0.1.1-rc.2",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/bundle/sdk-app"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./cordis.patch.yml": "./cordis.patch.yml",
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"cordis.patch.yml",
"lib/types/**/*.d.ts"
],
"license": "MIT",
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
}
},
"dependencies": {
"@deepseek-ai/dsh-cmdline": "workspace:^",
"@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:^",
"commander": "^15.0.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-include": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}
+48
View File
@@ -0,0 +1,48 @@
/**
* The SDK profile's command-line and stdin-lifetime provider. A successful
* parse publishes {@link SDK_APP_STARTUP_SERVICE}; the JSON-RPC server waits
* for that service, so help starts no transport.
* @module @deepseek-ai/dsh-sdk-app
*/
import { Command } from 'commander'
import type { Context } from '@deepseek-ai/cordis'
import { exitOnStdinEnd, parseCmdline } from '@deepseek-ai/dsh-cmdline'
/** Stable Cordis plugin name. */
export const name = 'sdk-app-startup'
/** Launcher service required before this app can parse its invocation. */
export const inject = ['cmdlineArgs']
/** Service the JSON-RPC server row waits for before claiming stdio. */
export const SDK_APP_STARTUP_SERVICE = 'sdkAppStartup'
/**
* Build this app's zero-option command and help.
* @returns a fresh program for one invocation.
*/
function sdkCommand(): Command {
return new Command()
.name('dsh --profile sdk')
.description('Serve DeepSeek Harness SDK clients over stdio JSON-RPC.')
.helpOption('-h, --help', 'show this help')
.addHelpText('after', `
Example:
dsh --profile sdk serve one SDK runtime until its client disconnects
`)
}
/**
* Accept an SDK profile invocation, publish readiness, and bind EOF to the
* launcher's bounded shutdown.
* @param ctx - plugin context carrying command-line and exit launcher values.
*/
export function apply(ctx: Context): void {
const program = sdkCommand()
program.action(() => {
exitOnStdinEnd(ctx, 'sdk-app.stdin')
ctx.provide(SDK_APP_STARTUP_SERVICE, { accepted: true })
})
parseCmdline(ctx, program)
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-sdk-app`.
* @module @deepseek-ai/dsh-sdk-app/invariant
*/
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-sdk-app'
/** Cordis companion plugin name. */
export const name = 'sdk-app-invariant'
/** Service required before the companion can register. */
export const inject = ['invariants']
/**
* No runtime invariant: the bundle adds a process transport and startup latch;
* source/built stdio tests own frame purity, help exclusion, and shutdown.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
@@ -0,0 +1,28 @@
/** The SDK app bundle's declared profile patch. */
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import * as yaml from 'js-yaml'
import { describe, expect, it } from 'vitest'
import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
describe('dsh-sdk-app bundle', () => {
it('declares startup-gated JSON-RPC serving with module HMR disabled', () => {
const root = fileURLToPath(new URL('..', import.meta.url))
const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as {
dependencies?: Record<string, string>
dsh?: { bundle?: { patch?: string } }
}
expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml')
expect(manifest.dependencies).toHaveProperty('@deepseek-ai/dsh-sdk-jsonrpc-server')
const patches = yaml.load(
readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'),
{ schema: entryListSchema },
) as Array<{ id?: string; disabled?: boolean; insert?: Array<{ id?: string; inject?: string[]; name?: string }> }>
expect(patches.find(patch => patch.id === 'hmr')).toMatchObject({ disabled: true })
const rows = patches.flatMap(patch => patch.insert ?? [])
expect(rows.find(row => row.id === 'sdk-app-startup')?.name).toBe('@deepseek-ai/dsh-sdk-app')
expect(rows.find(row => row.id === 'sdk-jsonrpc-server')?.inject).toEqual(['sdkAppStartup'])
})
})
@@ -0,0 +1,65 @@
/** The SDK app command provider and stdin shutdown binding. */
import { EventEmitter } from 'node:events'
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it } from 'vitest'
import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline'
import { apply, SDK_APP_STARTUP_SERVICE } from '../src/index.ts'
/** Controllable stdin for one startup invocation. */
class TestStdin extends EventEmitter {
readableEnded = false
resume(): this {
return this
}
end(): void {
this.readableEnded = true
this.emit('end')
}
}
afterEach(() => {
internals.stdin = process.stdin
internals.stdout = process.stdout
internals.stderr = process.stderr
})
/** Run the provider with captured command output and exit requests. */
function start(args: string[]): { ctx: Context; exits: number[]; out: () => string; stdin: TestStdin } {
const ctx = new Context()
const exits: number[] = []
const stdin = new TestStdin()
let out = ''
const capture = { write: (chunk: string) => { out += chunk; return true } }
internals.stdin = stdin
internals.stdout = capture
internals.stderr = capture
provideCmdline(ctx, {
args,
exit: code => void exits.push(code),
ready: { onReady: (listener) => { listener(); return () => {} } },
})
apply(ctx)
return { ctx, exits, out: () => out, stdin }
}
describe('SDK app startup', () => {
it('publishes readiness and requests bounded exit on client EOF', async () => {
const { ctx, exits, stdin } = start([])
expect(ctx.get(SDK_APP_STARTUP_SERVICE)).toEqual({ accepted: true })
stdin.end()
expect(exits).toEqual([0])
await ctx.fiber.dispose()
})
it('prints app help without publishing readiness or binding stdin', () => {
const { ctx, exits, out, stdin } = start(['--help'])
expect(out()).toContain('dsh --profile sdk')
expect(ctx.get(SDK_APP_STARTUP_SERVICE)).toBeUndefined()
expect(exits).toEqual([0])
stdin.end()
expect(exits).toEqual([0])
})
})
+21
View File
@@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../runtime-diagnostics/invariants"
},
{
"path": "../../boot/cmdline"
}
]
}
+25
View File
@@ -216,6 +216,9 @@ importers:
'@deepseek-ai/dsh-schedule':
specifier: workspace:^
version: link:../../packages/schedule/schedule
'@deepseek-ai/dsh-sdk-app':
specifier: workspace:^
version: link:../../packages/bundle/sdk-app
'@deepseek-ai/dsh-session-projection':
specifier: workspace:^
version: link:../../packages/session/session-projection
@@ -1327,6 +1330,28 @@ importers:
specifier: workspace:^
version: link:../../core/session
packages/bundle/sdk-app:
dependencies:
'@deepseek-ai/dsh-cmdline':
specifier: workspace:^
version: link:../../boot/cmdline
'@deepseek-ai/dsh-sdk-jsonrpc-server':
specifier: workspace:^
version: link:../../sdk/server
commander:
specifier: ^15.0.0
version: 15.0.0
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
'@deepseek-ai/cordis-plugin-include':
specifier: workspace:^
version: link:../../../vendor/include
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../runtime-diagnostics/invariants
packages/bundle/web-app:
dependencies:
'@deepseek-ai/dsh-agent-presets':
+1
View File
@@ -137,6 +137,7 @@ export const SERVICE_PAGE: Record<string, string> = {
*/
export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
agent: 'not a service: the DX accessor field on Agent.ctx (root accessor defaulting to undefined) — docs/subsystems/core.md owns the Agent handle',
appReady: 'not a service: launcher-provided successful-startup signal — packages/boot/cmdline/README.md owns the launcher contract',
appExit: 'not a service: launcher-provided bounded process-exit callback — packages/boot/cmdline/README.md owns the launcher contract',
cmdlineArgs: 'not a service: launcher-provided immutable app argument accessor — packages/boot/cmdline/README.md owns the launcher contract',
configuredAgentIdentities: 'not a service: launcher-provided boot-context value (ConfiguredAgentIdentities | undefined) — packages/core/agent-loop/README.md owns this launcher contract',
+1
View File
@@ -259,6 +259,7 @@
{ "path": "./packages/examples/acp-demo" },
{ "path": "./packages/bundle/base" },
{ "path": "./packages/bundle/headless" },
{ "path": "./packages/bundle/sdk-app" },
{ "path": "./packages/bundle/web-app" },
{ "path": "./packages/boot/app-boot" },
{ "path": "./packages/boot/cmdline" },