diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index ac271912ce..8585f56b9f 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -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 diff --git a/apps/cli/README.md b/apps/cli/README.md index eb44939213..89ddef296a 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -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. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 60e9cf0840..fa63d8189b 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -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` 可在不启动的情况下检查组合后的配置树。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 2b8bdebd54..5665a7cf63 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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:^", diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 09926316b9..3f67c98858 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -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 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index bc32f2a2f9..29e5937547 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -8,9 +8,9 @@ This reference defines the profile, web-alias, plugin-management, and config-dum `dsh --profile ` boots the profile at `$DSH_HOME/profiles/`. 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 ` 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 add `. +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 add `. ### 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. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 39fb2e7724..4202addbbc 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -8,9 +8,9 @@ `dsh --profile ` 启动位于 `$DSH_HOME/profiles/` 的 profile。生效配置树以空根节点为起点,依次叠加 profile manifest(元数据清单)的 `dsh.profile.bundles` 列表中指定的各组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(这是各 profile 共享的机器本地偏好,因此优先于逐 profile 配置层),以及按 argv 顺序指定的各个 `--patch ` 覆盖层。对同一配置行,后应用的层优先。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 add `。 +`web`、`headless` 和 `sdk` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app,实时应用 patch;`headless`:base + headless,只在启动时应用 patch;`sdk`:base + sdk-app,只在启动时应用 patch)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 ### 应用参数 @@ -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 写入任何内容,也不会打开监听端口。 diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 3d55fc89ba..bf44af1a6a 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -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 } } diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 75ab640fcc..2e4ef33b5f 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -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> => { + 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 + try { + value = JSON.parse(line.value) as Record + } 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({ diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 224df2127b..7cfa4d18ab 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b2cb3faea3..8e93605efd 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index c1e33e3724..4137a18d52 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -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)) diff --git a/knip.json b/knip.json index 7940454609..637c337481 100644 --- a/knip.json +++ b/knip.json @@ -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/.+" diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 0d8e53b6be..f62805364a 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -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 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 142f5145d3..081ce26727 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -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/` (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/` (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: diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 3139edd4e7..3c8e5186ac 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -35,7 +35,7 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 ## Profiles -profile 是位于 `$DSH_HOME/profiles/` 下的目录(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/` 下的目录(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 中: diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index 9d8efe8637..d64b9a7138 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -135,6 +135,10 @@ export const PROFILE_TEMPLATES: Record = { 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. */ diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index 0c085f59c8..61c9873818 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -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 { diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index 22a80a7e13..ad263beac9 100644 --- a/packages/boot/cmdline/README.i18n.yaml +++ b/packages/boot/cmdline/README.i18n.yaml @@ -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 diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index 3312501453..4a0244679e 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -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. diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md index 7ef49a1027..77063ccd3e 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -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 另有交互语义的应用不会调用它。 diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts index c053dcb95f..5e877f89e9 100644 --- a/packages/boot/cmdline/src/index.ts +++ b/packages/boot/cmdline/src/index.ts @@ -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 diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts index d05126a29f..6f41146c61 100644 --- a/packages/boot/cmdline/tests/cmdline.spec.ts +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -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)[] = [] +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 ', '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((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') + }) +}) diff --git a/packages/bundle/README.i18n.yaml b/packages/bundle/README.i18n.yaml index 49021a0c62..c726cb8eaf 100644 --- a/packages/bundle/README.i18n.yaml +++ b/packages/bundle/README.i18n.yaml @@ -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 diff --git a/packages/bundle/README.md b/packages/bundle/README.md index 4d7a064939..e0edabf2d2 100644 --- a/packages/bundle/README.md +++ b/packages/bundle/README.md @@ -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 add `. diff --git a/packages/bundle/README.zh.md b/packages/bundle/README.zh.md index 9bee067d77..56e1c41318 100644 --- a/packages/bundle/README.zh.md +++ b/packages/bundle/README.zh.md @@ -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 add ` 安装进 profile。 diff --git a/packages/bundle/sdk-app/README.i18n.yaml b/packages/bundle/sdk-app/README.i18n.yaml new file mode 100644 index 0000000000..843bd65dfa --- /dev/null +++ b/packages/bundle/sdk-app/README.i18n.yaml @@ -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 diff --git a/packages/bundle/sdk-app/README.md b/packages/bundle/sdk-app/README.md new file mode 100644 index 0000000000..d639007e41 --- /dev/null +++ b/packages/bundle/sdk-app/README.md @@ -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. diff --git a/packages/bundle/sdk-app/README.zh.md b/packages/bundle/sdk-app/README.zh.md new file mode 100644 index 0000000000..a3478fe854 --- /dev/null +++ b/packages/bundle/sdk-app/README.zh.md @@ -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 使用仅启动时 patch,profile 变化会在下一个进程生效。 + +## 已知限制与延期工作 + +- **profile 可以省略 SDK server**:TypeScript client 选择的自定义 profile 必须保留本组合包或另一个 `dsh-sdk-jsonrpc-server` 配置项;没有 peer 响应时,client 初始化会失败。 +- **用户插件可以破坏 stdout 纯净性**:profile 与逐次启动 patch 属于受信任应用组合。随附组合包不会向 stdout 写入非协议内容,但无法约束任意插入插件。 +- **配置变化需要重启**:随附 `sdk` profile 使用 `patchReload: startup`,因此一个 stdio 连接不会观察到 server 或 Agent 依赖被替换。 diff --git a/packages/bundle/sdk-app/cordis.patch.yml b/packages/bundle/sdk-app/cordis.patch.yml new file mode 100644 index 0000000000..3d81fd3670 --- /dev/null +++ b/packages/bundle/sdk-app/cordis.patch.yml @@ -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)" diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json new file mode 100644 index 0000000000..87214882fb --- /dev/null +++ b/packages/bundle/sdk-app/package.json @@ -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:^" + } +} diff --git a/packages/bundle/sdk-app/src/index.ts b/packages/bundle/sdk-app/src/index.ts new file mode 100644 index 0000000000..9ade81a965 --- /dev/null +++ b/packages/bundle/sdk-app/src/index.ts @@ -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) +} diff --git a/packages/bundle/sdk-app/src/invariant.ts b/packages/bundle/sdk-app/src/invariant.ts new file mode 100644 index 0000000000..c3e6c41d63 --- /dev/null +++ b/packages/bundle/sdk-app/src/invariant.ts @@ -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)) diff --git a/packages/bundle/sdk-app/tests/sdk-app.spec.ts b/packages/bundle/sdk-app/tests/sdk-app.spec.ts new file mode 100644 index 0000000000..5236364cc3 --- /dev/null +++ b/packages/bundle/sdk-app/tests/sdk-app.spec.ts @@ -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 + 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']) + }) +}) diff --git a/packages/bundle/sdk-app/tests/startup.spec.ts b/packages/bundle/sdk-app/tests/startup.spec.ts new file mode 100644 index 0000000000..65f2b76c5d --- /dev/null +++ b/packages/bundle/sdk-app/tests/startup.spec.ts @@ -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]) + }) +}) diff --git a/packages/bundle/sdk-app/tsconfig.json b/packages/bundle/sdk-app/tsconfig.json new file mode 100644 index 0000000000..1d644141bd --- /dev/null +++ b/packages/bundle/sdk-app/tsconfig.json @@ -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" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b562dce12b..87fd7a726b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 1cbb4ae28a..6b19d4220f 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -137,6 +137,7 @@ export const SERVICE_PAGE: Record = { */ export const SERVICE_WALK_EXEMPTIONS: Record = { 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', diff --git a/tsconfig.host.json b/tsconfig.host.json index 4b0b0e35db..1df0f5ca84 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -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" },