فهرست منبع

feat(profiles): add the ACP application bundle

Introduce @deepseek-ai/dsh-acp-app as the thin application layer for the built-in acp profile. It contributes only the ACP protocol bridge and profile metadata; dsh-base remains the single owner of shared agent composition, providers, persistence, permissions, and tools.

Wire the bundle into CLI resolution, catalogs, workspace configuration, and built-bin coverage. The focused bundle and startup tests prove that base plus acp-app exposes automation sessions while keeping stdout reserved for ACP JSON-RPC.
Tianyi Cui 2 هفته پیش
والد
کامیت
47a46e4cca

+ 2 - 2
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: 89ddef296a0ce4635eda351c9e40720ee2219fdc
-README.zh.md: fa63d8189ba87809104a55ca35a76a083372f1d8
+README.md: 9d34c374aa6f2de69e9c2b0ca838b6ddb9dd4054
+README.zh.md: ee6096d365a102d7107baf79be2fb35316c55c1a

+ 4 - 2
apps/cli/README.md

@@ -9,11 +9,13 @@ The `dsh` command is the product launcher for profiles: ordered stacks of plugin
 | Command | Purpose |
 |---|---|
 | `dsh --profile <name>` | Boot the named profile under `$DSH_HOME/profiles/<name>`. |
+| `dsh --profile acp` | Serve automation clients over ACP stdio until disconnect. |
 | `dsh --profile headless "job"` | Run one fresh persisted session, print the final answer, and exit. |
+| `dsh --profile sdk` | Serve SDK clients over JSON-RPC stdio until shutdown or disconnect. |
 | `dsh web` | Alias of `--profile web`. |
 | `dsh plugin --profile <name> <pnpm args>` | Manage a profile's plugins by forwarding to pnpm in the profile directory. |
 
-The invoking directory is the default workspace root. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`.
+The invoking directory is the default workspace root. The `web`, `headless`, `sdk`, and `acp` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`.
 
 ## App arguments
 
@@ -36,7 +38,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`, `@deepseek-ai/dsh-sdk-app`), 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`, `@deepseek-ai/dsh-acp-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.
 

+ 4 - 2
apps/cli/README.zh.md

@@ -9,11 +9,13 @@
 | 命令 | 用途 |
 |---|---|
 | `dsh --profile <name>` | 启动位于 `$DSH_HOME/profiles/<name>` 的指定 profile。 |
+| `dsh --profile acp` | 通过 ACP stdio 为自动化 client 提供服务,直至断开连接。 |
 | `dsh --profile headless "job"` | 运行一个全新的持久化会话,打印最终答案并退出。 |
+| `dsh --profile sdk` | 通过 JSON-RPC stdio 为 SDK client 提供服务,直至关闭或断开连接。 |
 | `dsh web` | `--profile web` 的别名。 |
 | `dsh plugin --profile <name> <pnpm args>` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 |
 
-运行命令时所在的目录将作为默认 workspace 根目录。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。
+运行命令时所在的目录将作为默认 workspace 根目录。`web`、`headless`、`sdk` 和 `acp` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。
 
 ## 应用参数
 
@@ -38,7 +40,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`、`@deepseek-ai/dsh-sdk-app`),再从 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`、`@deepseek-ai/dsh-acp-app`),再从 profile 自身的 `node_modules` 解析;pnpm 会将树外插件安装到该目录。
 
 使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。
 

+ 2 - 0
apps/cli/package.json

@@ -29,6 +29,7 @@
     "@deepseek-ai/cordis-plugin-include": "workspace:^",
     "@deepseek-ai/cordis-plugin-loader": "workspace:^",
     "@deepseek-ai/cordis-plugin-timer": "workspace:^",
+    "@deepseek-ai/dsh-acp-app": "workspace:^",
     "@deepseek-ai/dsh-agent-tool-presentation": "workspace:^",
     "@deepseek-ai/dsh-app-boot": "workspace:^",
     "@deepseek-ai/dsh-base": "workspace:^",
@@ -92,6 +93,7 @@
     "node-addon-require-builtin": "^0.1.4"
   },
   "devDependencies": {
+    "@agentclientprotocol/sdk": "0.25.1",
     "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-host-frontend-static": "workspace:^",
     "@deepseek-ai/dsh-host-apiproxy": "workspace:^",

+ 2 - 2
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: 29e5937547b2175a003f036a1a1d70e124d19ad7
-README.zh.md: 4202addbbc97383ef4fe9ae4c1889304a81e49ca
+README.md: cac27cbe7d7a9b1fb3a47488d40f8bf82543c34e
+README.zh.md: d2f1754da0f98af6a565afacf7724f69f84574da

+ 3 - 2
apps/cli/reference/README.md

@@ -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`, `@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).
+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`, `@deepseek-ai/dsh-acp-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`, `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>`.
+The `web`, `headless`, `sdk`, and `acp` 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; `acp`: base + acp-app with startup-only patches). Any other missing profile fails loud with a hint to run `dsh plugin --profile <name> add <package>`.
 
 ### App arguments
 
@@ -27,6 +27,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 |
+| `acp` | no options; stdio carries Agent Client 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
apps/cli/reference/README.zh.md

@@ -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`、`@deepseek-ai/dsh-sdk-app`)始终来自当前运行的 `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`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包则来自 profile 中由 pnpm 管理的 `node_modules`。patch 行中的裸插件 `name` 会从 profile 目录开始,按照 Node 的模块解析规则逐级向父目录查找,直至由 dsh 维护的安装后备目录 `$DSH_HOME/profiles/node_modules`。该目录为 dsh 安装中的应用和组合包所依赖的每个包各维护一个符号链接,并在每次启动时修复这些链接。
 
-`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>`。
+`web`、`headless`、`sdk` 和 `acp` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app,实时应用 patch;`headless`:base + headless,只在启动时应用 patch;`sdk`:base + sdk-app,只在启动时应用 patch;`acp`:base + acp-app,只在启动时应用 patch)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile <name> add <package>`。
 
 ### 应用参数
 
@@ -27,6 +27,7 @@
 | `web` | `--host`、`--port`、可重复的 `--trusted-host`、`--no-open` |
 | `headless` | 任务文本,作为位置参数 |
 | `sdk` | 无选项;stdio 携带 JSON-RPC 协议 |
+| `acp` | 无选项;stdio 携带 Agent Client Protocol |
 
 一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对会话执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。
 

+ 78 - 0
apps/cli/tests/built-bin.e2e.ts

@@ -2,7 +2,18 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { createInterface } from 'node:readline'
+import { Readable, Writable } from 'node:stream'
 import { fileURLToPath, pathToFileURL } from 'node:url'
+import {
+  ClientSideConnection,
+  ndJsonStream,
+  PROTOCOL_VERSION,
+  type Agent as AcpAgent,
+  type Client as AcpClient,
+  type RequestPermissionRequest,
+  type RequestPermissionResponse,
+  type SessionNotification,
+} from '@agentclientprotocol/sdk'
 import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
 import { execa } from 'execa'
 import { afterEach, beforeEach, describe, expect, it } from 'vitest'
@@ -365,6 +376,14 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       expect(sdkHelp.stderr).toBe('')
       expect(sdkHelp.stdout).toContain('Usage: dsh --profile sdk')
 
+      const acpHelp = await runBuiltBin(['--profile', 'acp', '--help'], {
+        DSH_HOME: home,
+        DSH_TELEMETRY_DISABLED: '1',
+      })
+      expect(acpHelp.code).toBe(0)
+      expect(acpHelp.stderr).toBe('')
+      expect(acpHelp.stdout).toContain('Usage: dsh --profile acp')
+
       const missingTask = await runBuiltBin(['--profile', 'headless'], {
         DSH_HOME: home,
         DSH_TELEMETRY_DISABLED: '1',
@@ -455,6 +474,65 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     }
   }, 30_000)
 
+  it('serves fresh ACP sessions through the acp profile and exits on disconnect', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'dsh-built-acp-'))
+    const child = execa(process.execPath, [dshBin, '--profile', 'acp'], {
+      cwd: home,
+      reject: false,
+      timeout: 25_000,
+      killSignal: 'SIGKILL',
+      env: {
+        ...process.env,
+        DSH_HOME: home,
+        DSH_TELEMETRY_DISABLED: '1',
+        DEEPSEEK_API_KEY: 'built-acp-profile-no-call',
+      },
+      extendEnv: false,
+    })
+    const rawOut: string[] = []
+    const passthrough = new Readable({ read() {} })
+    child.stdout.on('data', (chunk: Buffer) => {
+      rawOut.push(chunk.toString('utf8'))
+      passthrough.push(chunk)
+    })
+    child.stdout.on('end', () => { passthrough.push(null) })
+    const stream = ndJsonStream(
+      Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
+      Readable.toWeb(passthrough) as ReadableStream<Uint8Array>,
+    )
+    const makeClient = (_agent: AcpAgent): AcpClient => ({
+      sessionUpdate(_params: SessionNotification): Promise<void> {
+        return Promise.resolve()
+      },
+      requestPermission(_params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
+        return Promise.resolve({ outcome: { outcome: 'cancelled' } })
+      },
+    })
+    const client = new ClientSideConnection(makeClient, stream)
+    try {
+      const initialized = await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+      expect(initialized).toMatchObject({
+        agentInfo: { name: 'deepseek-harness-acp' },
+        agentCapabilities: {
+          promptCapabilities: { image: false, audio: false, embeddedContext: false },
+        },
+      })
+      const session = await client.newSession({ cwd: home, mcpServers: [] })
+      expect(session.sessionId).toBeTruthy()
+      child.stdin.end()
+      const result = await child
+      expect(result.exitCode, `signal=${String(result.signal)}; stderr=${result.stderr}`).toBe(0)
+      expect(result.stderr).toBe('')
+      for (const line of rawOut.join('').split('\n').filter(value => value.trim() !== '')) {
+        expect(() => JSON.parse(line) as unknown).not.toThrow()
+      }
+    } 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
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: 8e93605efd023c30f90108b2c46138984e2fbcd6
-config-catalog.zh.md: 4137a18d5218e1512809ead16769233a26f476cc
+config-catalog.md: 7cedd84d09dc12beeade5e569f94daa14c215d91
+config-catalog.zh.md: 743b9d1cb8fca952b9f232115ecaed25e1e0a6ed

+ 1 - 0
docs/config-catalog.md

@@ -3278,6 +3278,7 @@ Source: [`packages/workflow/workflow-worker-thread/src/index.ts:32`](../packages
 
 These load from a `cordis.yml` entry with no `config:` block; they declare no configuration API.
 
+- `@deepseek-ai/dsh-acp-app` — requires `cmdlineArgs` ([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts))
 - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
 - `@deepseek-ai/dsh-api-gateway` — requires `typert` ([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts))
 - `@deepseek-ai/dsh-api-remotes` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts))

+ 1 - 0
docs/config-catalog.zh.md

@@ -3280,6 +3280,7 @@ export interface Config {
 
 这些插件通过 `cordis.yml` 中不含 `config:` 块的条目加载;它们未声明任何配置接口。
 
+- `@deepseek-ai/dsh-acp-app` — 需要 `cmdlineArgs`([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts))
 - `@deepseek-ai/dsh-agent`([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
 - `@deepseek-ai/dsh-api-gateway` — 需要 `typert`([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts))
 - `@deepseek-ai/dsh-api-remotes`([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts))

+ 5 - 0
knip.json

@@ -676,6 +676,11 @@
         "@deepseek-ai/dsh-code-runtime-worker-thread"
       ]
     },
+    "packages/bundle/acp-app": {
+      "ignoreDependencies": [
+        "@deepseek-ai/dsh-acp"
+      ]
+    },
     "packages/bundle/sdk-app": {
       "ignoreDependencies": [
         "@deepseek-ai/dsh-sdk-jsonrpc-server"

+ 2 - 2
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: 081ce26727790e79d5bd1a23f396b2b35de4b78e
-README.zh.md: 3c8e5186acfb164671ad21a65342032dde226c49
+README.md: 791a0c31e0582b5494221397fbd8171508d5c12f
+README.zh.md: 5cb6dcde7722c2c63f36873b6f402ec957dcb91a

+ 1 - 1
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/<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.
+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`/`acp` 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
packages/boot/app-boot/README.zh.md

@@ -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`/`sdk`;其他名称在通过 `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`/`acp`;其他名称在通过 `dsh plugin` 由 `initProfile` 创建前都会明确报错。`loadProfile` 会把安装自有的精确组合包元组和缺失的重载选择规范化为随附模板,同时保留每个显式重载选择和 manifest 中其他所有字段;组合包一旦有任何额外、缺失或重排,列表就归用户所有并保持不变。
 
 用户级的机器本地偏好同样位于 harness home 中:
 

+ 4 - 0
packages/boot/app-boot/src/profile.ts

@@ -127,6 +127,10 @@ export function resolveProfileDir(name: string, home: string = resolveDshHome())
 
 /** The shipped profile templates auto-initialized on first use, by name. */
 export const PROFILE_TEMPLATES: Record<string, ProfileTemplate> = {
+  acp: {
+    bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-acp-app'],
+    patchReload: 'startup',
+  },
   web: {
     bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],
     patchReload: 'live',

+ 4 - 0
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.acp).toEqual({
+      bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-acp-app'],
+      patchReload: 'startup',
+    })
     expect(PROFILE_TEMPLATES.sdk).toEqual({
       bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-sdk-app'],
       patchReload: 'startup',

+ 2 - 2
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: e0edabf2d2777eafca0773c8472f011f787e5a15
-README.zh.md: 56e1c4131822a59ca8135c6bba062fe634346578
+README.md: d6b24a276fa64bb2eb80c2aad1783795e351ebc4
+README.zh.md: 36acc510cfab7979d28052ab26687dce58175155

+ 1 - 0
packages/bundle/README.md

@@ -9,6 +9,7 @@ The manifest declaration, not this directory, defines Bundle identity. Domain pa
 | Package | Role | ctx key |
 |---|---|---|
 | [`base/`](base/README.md) | The shared dsh core every profile applies first | — (patch only) |
+| [`acp-app/`](acp-app/README.md) | Automation-only ACP stdio application over base | mounts the ACP bridge |
 | [`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 |

+ 1 - 0
packages/bundle/README.zh.md

@@ -9,6 +9,7 @@ Bundle 身份由 manifest 声明决定,而不是由本目录决定。领域包
 | 包 | 职责 | ctx key |
 |---|---|---|
 | [`base/`](base/README.zh.md) | 每个 profile 最先应用的共享 dsh 核心 | —(仅 patch) |
+| [`acp-app/`](acp-app/README.zh.md) | 运行在 base 之上的 automation-only ACP stdio 应用 | 挂载 ACP bridge |
 | [`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 |

+ 6 - 0
packages/bundle/acp-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/acp-app/README.md
+README.md: 5c0910dcdef2ddffe67aef29e14db371272238ee
+README.zh.md: 7a70a1b34b1355095884a024b8192f6b10453143

+ 31 - 0
packages/bundle/acp-app/README.md

@@ -0,0 +1,31 @@
+# `@deepseek-ai/dsh-acp-app`
+
+English | [中文](README.zh.md)
+
+The automation-only ACP stdio application as a `dsh` profile bundle over [`dsh-base`](../base/README.md). Its patch sets the coding-agent persona and default model route, disables module HMR, mounts an app-owned zero-option command provider, and starts [`dsh-acp`](../../acp/acp/README.md) only after that provider accepts the invocation. `dsh --profile acp --help` therefore writes help and exits without claiming stdin or stdout.
+
+The startup provider binds stdin EOF to the launcher's bounded successful shutdown. ACP connection close, SIGINT, and SIGTERM drain the bridge-owned agents and the root profile tree before exit. Stdout is reserved for newline-delimited ACP JSON-RPC frames. A deployment selects a different complete composition through profile bundles and patch files, not another app bin.
+
+The shipped row creates sessions with `deepseek-official` and `deepseek-v4-flash`; a later patch can replace that row's complete config. The base profile owns adapters, tools, persistence, policy, settings, credentials, and the per-session workspace supplied by the ACP client.
+
+## Model Experience
+
+### ACP 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 ACP row's route and each `session/new` 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 ACP profile uses startup-only patches.
+
+## Known Limitations and Deferred Work
+
+- **A profile can omit the ACP bridge** — a custom ACP launch profile must retain this bundle or another `dsh-acp` row; otherwise no peer answers the client.
+- **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 `acp` profile uses `patchReload: startup` so one stdio connection never observes a replacement bridge or Agent dependency.

+ 31 - 0
packages/bundle/acp-app/README.zh.md

@@ -0,0 +1,31 @@
+# `@deepseek-ai/dsh-acp-app`
+
+[English](README.md) | 中文
+
+以 [`dsh-base`](../base/README.zh.md) 为基础的 automation-only ACP stdio 应用 `dsh` profile 组合包。其 patch 设置 coding agent(编程智能体)persona 与默认模型路由、禁用模块 HMR(热模块替换)、挂载应用自有的零选项命令提供方,并且只在该提供方接受调用后启动 [`dsh-acp`](../../acp/acp/README.zh.md)。因此,`dsh --profile acp --help` 会写出 help 并退出,不会占用 stdin 或 stdout。
+
+启动提供方把 stdin EOF 绑定到启动器的有界成功关闭。ACP 连接关闭、SIGINT 与 SIGTERM 会在退出前排空 bridge 自有 agent 以及根 profile 树。Stdout 仅保留给换行分隔的 ACP JSON-RPC frame。部署方通过 profile 组合包与 patch 文件选择另一套完整组合,而不是使用另一个 app bin。
+
+随附配置项使用 `deepseek-official` 与 `deepseek-v4-flash` 创建 session;后续 patch 可以替换该配置项的完整 config。base profile 负责适配器、工具、持久化、策略、settings 与 credentials;ACP client 为每个 session 提供工作区。
+
+## 模型体验
+
+### ACP coding-agent persona
+
+#### 模型看到什么
+
+在 base 的工具和上下文贡献之前,profile 提供 `You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.`。ACP 配置项的路由与每个 `session/new` 的 cwd 会解析其中的占位符。
+
+#### Token 影响
+
+一段简短稳定的 persona,加上随数据变化的 base prompt section 与已选工具 schema。
+
+#### KV Cache 影响
+
+固定 profile、提供方、模型与工具集合下保持稳定。随附 ACP profile 只在启动时加载 patch,因此 profile 更改会在下一个进程生效。
+
+## 已知限制与待办事项
+
+- **profile 可以省略 ACP bridge**:自定义 ACP 启动 profile 必须保留本组合包或另一个 `dsh-acp` 配置项;否则没有 peer 响应 client。
+- **用户插件可能破坏 stdout 纯净性**:profile 与单次启动 patch 属于受信任的应用组合。随附组合包不会向 stdout 写入非协议内容,但无法约束任意插入的插件。
+- **配置更改需要重启**:随附 `acp` profile 使用 `patchReload: startup`,确保一条 stdio 连接不会观察到 bridge 或 Agent 依赖被替换。

+ 20 - 0
packages/bundle/acp-app/cordis.patch.yml

@@ -0,0 +1,20 @@
+# The automation-only ACP application over dsh-base. Stdout belongs to ACP.
+
+- 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: acp-app-startup
+      name: '@deepseek-ai/dsh-acp-app'
+
+    - id: acp
+      name: '@deepseek-ai/dsh-acp'
+      inject: [acpAppStartup]
+      config:
+        provider: deepseek-official
+        model: deepseek-v4-flash

+ 55 - 0
packages/bundle/acp-app/package.json

@@ -0,0 +1,55 @@
+{
+  "name": "@deepseek-ai/dsh-acp-app",
+  "description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio 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/acp-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-acp": "workspace:^",
+    "@deepseek-ai/dsh-cmdline": "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 - 0
packages/bundle/acp-app/src/index.ts

@@ -0,0 +1,48 @@
+/**
+ * The ACP profile's command-line and stdin-lifetime provider. A successful
+ * parse publishes {@link ACP_APP_STARTUP_SERVICE}; the ACP bridge waits for
+ * that service, so help starts no transport.
+ * @module @deepseek-ai/dsh-acp-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 = 'acp-app-startup'
+
+/** Launcher service required before this app can parse its invocation. */
+export const inject = ['cmdlineArgs']
+
+/** Service the ACP bridge row waits for before claiming stdio. */
+export const ACP_APP_STARTUP_SERVICE = 'acpAppStartup'
+
+/**
+ * Build this app's zero-option command and help.
+ * @returns a fresh program for one invocation.
+ */
+function acpCommand(): Command {
+  return new Command()
+    .name('dsh --profile acp')
+    .description('Serve automation clients over Agent Client Protocol stdio.')
+    .helpOption('-h, --help', 'show this help')
+    .addHelpText('after', `
+Example:
+  dsh --profile acp     serve ACP until the client disconnects
+`)
+}
+
+/**
+ * Accept an ACP 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 = acpCommand()
+  program.action(() => {
+    exitOnStdinEnd(ctx, 'acp-app.stdin')
+    ctx.provide(ACP_APP_STARTUP_SERVICE, { accepted: true })
+  })
+  parseCmdline(ctx, program)
+}

+ 28 - 0
packages/bundle/acp-app/src/invariant.ts

@@ -0,0 +1,28 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-acp-app`.
+ * @module @deepseek-ai/dsh-acp-app/invariant
+ */
+
+import type { Context } from '@deepseek-ai/cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-acp-app'
+
+/** Cordis companion plugin name. */
+export const name = 'acp-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))

+ 35 - 0
packages/bundle/acp-app/tests/acp-app.spec.ts

@@ -0,0 +1,35 @@
+/** The ACP 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-acp-app bundle', () => {
+  it('declares startup-gated ACP 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-acp')
+    const patches = yaml.load(
+      readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'),
+      { schema: entryListSchema },
+    ) as Array<{
+      id?: string
+      disabled?: boolean
+      insert?: Array<{ config?: { model?: string; provider?: string }; 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 === 'acp-app-startup')?.name).toBe('@deepseek-ai/dsh-acp-app')
+    expect(rows.find(row => row.id === 'acp')).toMatchObject({
+      inject: ['acpAppStartup'],
+      config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+    })
+  })
+})

+ 65 - 0
packages/bundle/acp-app/tests/startup.spec.ts

@@ -0,0 +1,65 @@
+/** The ACP 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 { ACP_APP_STARTUP_SERVICE, apply } 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('ACP app startup', () => {
+  it('publishes readiness and requests bounded exit on client EOF', async () => {
+    const { ctx, exits, stdin } = start([])
+    expect(ctx.get(ACP_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 acp')
+    expect(ctx.get(ACP_APP_STARTUP_SERVICE)).toBeUndefined()
+    expect(exits).toEqual([0])
+    stdin.end()
+    expect(exits).toEqual([0])
+  })
+})

+ 21 - 0
packages/bundle/acp-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"
+    }
+  ]
+}

+ 28 - 0
pnpm-lock.yaml

@@ -141,6 +141,9 @@ importers:
       '@deepseek-ai/cordis-plugin-timer':
         specifier: workspace:^
         version: link:../../vendor/timer
+      '@deepseek-ai/dsh-acp-app':
+        specifier: workspace:^
+        version: link:../../packages/bundle/acp-app
       '@deepseek-ai/dsh-agent-instructions':
         specifier: workspace:^
         version: link:../../packages/context/agent-instructions
@@ -322,6 +325,9 @@ importers:
         specifier: ^0.1.4
         version: 0.1.4
     devDependencies:
+      '@agentclientprotocol/sdk':
+        specifier: 0.25.1
+        version: 0.25.1(zod@4.4.3)
       '@deepseek-ai/dsh-agent':
         specifier: workspace:^
         version: link:../../packages/core/agent
@@ -1293,6 +1299,28 @@ importers:
         specifier: workspace:^
         version: link:../../runtime-diagnostics/invariants
 
+  packages/bundle/acp-app:
+    dependencies:
+      '@deepseek-ai/dsh-acp':
+        specifier: workspace:^
+        version: link:../../acp/acp
+      '@deepseek-ai/dsh-cmdline':
+        specifier: workspace:^
+        version: link:../../boot/cmdline
+      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/headless:
     dependencies:
       '@deepseek-ai/dsh-cmdline':

+ 1 - 0
tsconfig.host.json

@@ -257,6 +257,7 @@
     { "path": "./packages/test-support/agent-loop-testkit" },
     { "path": "./packages/acp/acp" },
     { "path": "./packages/examples/acp-demo" },
+    { "path": "./packages/bundle/acp-app" },
     { "path": "./packages/bundle/base" },
     { "path": "./packages/bundle/headless" },
     { "path": "./packages/bundle/sdk-app" },