Преглед на файлове

Merge remote-tracking branch 'origin/master' into codex/consolidate-agent-notes

Turtle преди 2 месеца
родител
ревизия
38448a389e
променени са 29 файла, в които са добавени 226 реда и са изтрити 1492 реда
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml
  2. 2 2
      .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md
  3. 2 2
      .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md
  4. 1 1
      apps/cli/README.md
  5. 0 1
      apps/cli/package.json
  6. 26 20
      apps/cli/src/app-cli-entry.ts
  7. 22 17
      apps/cli/src/headless.ts
  8. 0 3
      apps/cli/tsconfig.json
  9. 0 1
      docs/config-catalog.md
  10. 0 3
      docs/module-graph.md
  11. 0 5
      knip.json
  12. 1 1
      packages/host/apiproxy/README.md
  13. 1 1
      packages/host/apiproxy/src/api-proxy.ts
  14. 0 0
      packages/host/apiproxy/tests/api-proxy-cold.spec.ts
  15. 0 0
      packages/host/apiproxy/tests/api-proxy-view.spec.ts
  16. 0 35
      packages/host/runtime/README.md
  17. 0 77
      packages/host/runtime/package.json
  18. 0 171
      packages/host/runtime/src/boot.ts
  19. 0 11
      packages/host/runtime/src/index.ts
  20. 0 31
      packages/host/runtime/src/invariant.ts
  21. 0 54
      packages/host/runtime/src/start.ts
  22. 0 792
      packages/host/runtime/tests/host-runtime.spec.ts
  23. 0 132
      packages/host/runtime/tsconfig.json
  24. 168 0
      packages/host/webserver/tests/webserver.spec.ts
  25. 0 127
      pnpm-lock.yaml
  26. 0 1
      scripts/verify-package-readme-model-experience.ts
  27. 0 1
      tsconfig.base.json
  28. 0 1
      tsconfig.host.json
  29. 1 0
      vitest.config.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.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
-2026-07-24-web-config-tree-boot-and-transport-layering.md: 9e93b828d5f11060aa476396f6981320c33485a5
-2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 996a5705bd5d00a2163a146ef8210247f512e6fa
+2026-07-24-web-config-tree-boot-and-transport-layering.md: 377ebd2b3cf9ff1dff81dd3546bb262e0ebde88a
+2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 403e95fb3088d2164d50710d2a69de5526807764

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md

@@ -18,14 +18,14 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)
 
 **Config sources have one declaration place each.** yml static values are engineering defaults; the profile json (`./.dsh-tmp-profile/config.json`, read-only, never created, cwd-anchored until the `$DSH_HOME` migration) is user config mapped through a static `PROFILE_MAPPINGS` table onto target rows (`provider`/`model` → the `api-gateway` row, `persistenceRoot` → the jsonl row); CLI flags map onto the `webserver` row with a field set disjoint from the json's; env values enter through yml `!!js` expressions, never through the mapping table. Patches replace a row's config wholesale, so the entry class re-reads the yml row's static values (bypass parse) and merges overrides on top. An unmapped json key fails loud. The resolved frontend `distIndex` rides the same patch channel — an assembly fact, not user config.
 
-**The transport splits five ways.** `dsh-host-apiproxy` upgraded to the gateway plugin (`api-gateway` row): default-exports `ApiProxyService`, config `{provider, model}`, provides `ctx.apiProxy`, transport-agnostic and registers no routes — `createApiProxy` moved here from runtime (dependency direction allows it; runtime keeps `bootHost`/`startHost` for headless). `dsh-host-webserver` shrank to a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, per-request failures answer 400 and log without exiting, and knows no harness concepts. The connection node half owns the binding: it injects both services and registers `toFetchHandler(ctx.apiProxy)` under the `/api` prefix — future IPC carriers swap connection's transport while the gateway stays untouched. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns the graph: incremental per-package scanning (no full-rescan code path — `internal/plugin` marks the fiber's entry name dirty, a flush reconciles each name against live entries, package metadata including negative verdicts is cached forever, re-hashing is reachable only through `rebuilt(id)`), the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload: `fs.watchFile` stat-polling driven by `onGraphChanged` membership, and the `/plugins/events` SSE route.
+**The transport splits five ways.** `dsh-host-apiproxy` upgraded to the gateway plugin (`api-gateway` row): default-exports `ApiProxyService`, config `{provider, model}`, provides `ctx.apiProxy`, transport-agnostic and registers no routes — `createApiProxy` moved here from the retired runtime package. `dsh-host-webserver` shrank to a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, per-request failures answer 400 and log without exiting, and knows no harness concepts. The connection node half owns the binding: it injects both services and registers `toFetchHandler(ctx.apiProxy)` under the `/api` prefix — future IPC carriers swap connection's transport while the gateway stays untouched. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns the graph: incremental per-package scanning (no full-rescan code path — `internal/plugin` marks the fiber's entry name dirty, a flush reconciles each name against live entries, package metadata including negative verdicts is cached forever, re-hashing is reachable only through `rebuilt(id)`), the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload: `fs.watchFile` stat-polling driven by `onGraphChanged` membership, and the `/plugins/events` SSE route.
 
 **Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`.
 
 ## Consequences
 
 - Recomposing a web deployment is a yml/patch edit; the retired pieces (`mountWebPlugins`, `CLIENT_PACKAGES`, `createHostWebPluginRegistry`, `startWebServer`, the webserver's graph/SSE/api knowledge) are deleted.
-- Headless still boots through `bootHost` (unchanged this round); its migration, the profile write path, the `$DSH_HOME` profile relocation, and IPC carriers are recorded deferrals in the design ledger.
+- Headless boots the same composition through the same entry (landed in the stacked follow-up): port 0 is its only surface difference, the model face gains `ask_user_question`/workspace context/model titles per the unification ruling, and `bootHost`/`startHost` retired with the `dsh-host-runtime` package. The profile write path, the `$DSH_HOME` profile relocation, and IPC carriers remain recorded deferrals.
 - A TypeScript pitfall worth remembering: a `declare module 'cordis'` augmentation in a file with **no cordis import** is demoted to a standalone module declaration and silently shatters the program-wide `Context` merge (`ctx.on`/`ctx.effect` vanish across the program). Anchor with `import type {} from 'cordis'`.
 
 ## Alternatives considered

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md

@@ -18,14 +18,14 @@ Status: implemented
 
 **每个配置源有唯一声明位置。** yml 静态值是工程默认;profile json(`./.dsh-tmp-profile/config.json`,只读、绝不创建、暂锚 cwd 直至 `$DSH_HOME` 迁移)是用户配置,经静态 `PROFILE_MAPPINGS` 表映射到目标行(`provider`/`model` → `api-gateway` 行,`persistenceRoot` → jsonl 行);CLI flags 映射到 `webserver` 行、字段集与 json 不相交;env 值经 yml `!!js` 表达式进入,绝不进映射表。patch 整体替换行 config,故 entry 类旁路 parse 重读 yml 行静态值再叠加覆盖。未映射的 json 键 fail loud。解析出的前端 `distIndex` 走同一 patch 通道——装配事实,不是用户配置。
 
-**传输五分。** `dsh-host-apiproxy` 升格网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,config `{provider, model}`,provide `ctx.apiProxy`,传输无关、不注册路由——`createApiProxy` 从 runtime 迁入(依赖方向允许;runtime 保留 `bootHost`/`startHost` 供 headless)。`dsh-host-webserver` 缩成朴素路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败答 400 并记日志不退进程,不认识任何 harness 概念。connection node 半拥有绑定:inject 两个服务,把 `toFetchHandler(ctx.apiProxy)` 注册在 `/api` 前缀下——将来 IPC 载体只换 connection 的传输,网关零改动。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有图:单包增量扫描(无全量重扫路径——`internal/plugin` 把 fiber 的 entry 名标脏,flush 逐名对账 live entries,包元数据含否定结论永久缓存,重哈希唯一入口 `rebuilt(id)`)、bundle 路由、index tap、`onRebuilt`/`onGraphChanged` 通知。hmr node 半拥有开发期重载:`fs.watchFile` stat 轮询、watch 集合跟随 `onGraphChanged`、`/plugins/events` SSE 路由。
+**传输五分。** `dsh-host-apiproxy` 升格网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,config `{provider, model}`,provide `ctx.apiProxy`,传输无关、不注册路由——`createApiProxy` 自已退役的 runtime 包迁入。`dsh-host-webserver` 缩成朴素路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败答 400 并记日志不退进程,不认识任何 harness 概念。connection node 半拥有绑定:inject 两个服务,把 `toFetchHandler(ctx.apiProxy)` 注册在 `/api` 前缀下——将来 IPC 载体只换 connection 的传输,网关零改动。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有图:单包增量扫描(无全量重扫路径——`internal/plugin` 把 fiber 的 entry 名标脏,flush 逐名对账 live entries,包元数据含否定结论永久缓存,重哈希唯一入口 `rebuilt(id)`)、bundle 路由、index tap、`onRebuilt`/`onGraphChanged` 通知。hmr node 半拥有开发期重载:`fs.watchFile` stat 轮询、watch 集合跟随 `onGraphChanged`、`/plugins/events` SSE 路由。
 
 **包出口纪律。** modules 包只暴露 `.`(node 半)与 `./client`(完整浏览器半:`ClientModuleSystem`、`parseBootManifest`、收编插件面)——不设特设子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__`;`./client` 的 apply 读槽(缺槽大声抛)并 provide `ctx.modules`。
 
 ## 后果
 
 - 重组一个 web 部署 = 改 yml/patch;退役件(`mountWebPlugins`、`CLIENT_PACKAGES`、`createHostWebPluginRegistry`、`startWebServer`、webserver 的图/SSE/api 知识)全部删除。
-- headless 本轮仍走 `bootHost`;它的迁移、profile 写入路径、profile 迁 `$DSH_HOME`、IPC 载体,均为设计台账中的挂账项。
+- headless 已在 stacked 后续轮迁入同一组合同一入口:唯一面差异是 port 0,模型面按统一裁决获得 `ask_user_question`/workspace context/模型标题,`bootHost`/`startHost` 随 `dsh-host-runtime` 包退役。profile 写入路径、profile 迁 `$DSH_HOME`、IPC 载体仍为挂账项。
 - 一个值得记住的 TypeScript 坑:`declare module 'cordis'` augmentation 所在文件若**没有任何 cordis import**,会被降级成独立 module declaration,无声打散全程序的 `Context` merge(`ctx.on`/`ctx.effect` 全程序消失)。用 `import type {} from 'cordis'` 锚定。
 
 ## Alternatives considered

+ 1 - 1
apps/cli/README.md

@@ -10,7 +10,7 @@ The TUI surface:
 - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
 - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree.
 
-The Web surface treats its invoking directory as the default project, loads applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opts into first-message model titles. The headless surface retains deterministic fallback titles without making the auxiliary title-model request.
+The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
 
 ## Install (developer machine)
 

+ 0 - 1
apps/cli/package.json

@@ -37,7 +37,6 @@
     "@deepseek-ai/dsh-fs-local": "workspace:^",
     "@deepseek-ai/dsh-fs-policy": "workspace:^",
     "@deepseek-ai/dsh-host-apiproxy": "workspace:^",
-    "@deepseek-ai/dsh-host-runtime": "workspace:^",
     "@deepseek-ai/dsh-host-webserver": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-llm-deepseek": "workspace:^",

+ 26 - 20
apps/cli/src/app-cli-entry.ts

@@ -1,8 +1,8 @@
 /**
- * AppCLIEntry — the pre-cordis boot glue every dsh surface shape shares
- * (config-tree boot wired for `dsh web` this round; TUI/headless migrate
- * later). Everything here is what must exist before the Loader runs: layered
- * env, the patch composition over the shipped cordis.yml (profile json + CLI
+ * AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share
+ * (`dsh web` and `dsh -p` boot the one composition; TUI migrates later).
+ * Everything here is what must exist before the Loader runs: layered env,
+ * the patch composition over the shipped cordis.yml (profile json + CLI
  * flags + the resolved frontend dist), and the fail-loud triple after the
  * tree settles.
  */
@@ -62,22 +62,28 @@ const includeYamlSchema = yaml.JSON_SCHEMA.extend(jsExprType)
 const FIBER_ACTIVE = 2 as FiberState.ACTIVE
 const FIBER_PENDING = 0 as FiberState.PENDING
 
-/** Constructor facts for one `dsh web` invocation (argv already parsed by web.ts). */
+/** Constructor facts for one dsh invocation over the shared composition (argv already parsed by the surface bin). */
 export interface AppCLIEntryOptions {
   /** Absolute path of the shipped cordis.yml. */
   configPath: string
-  /** Whether to append the HMR row (the whole prod/dev difference). */
+  /** Whether to append the HMR row (the whole prod/dev difference; web surface only). */
   dev: boolean
   /** --host when explicitly passed; undefined keeps the yml engineering default. */
   host?: string
-  /** --port when explicitly passed; undefined keeps the yml engineering default. */
+  /**
+   * Listen port override onto the webserver row. Web passes the --port flag
+   * value; headless passes 0 (an OS-assigned port, so parallel `dsh -p` runs
+   * never collide — and the printed URL still opens the live session in a
+   * browser).
+   */
   port?: number
 }
 
 /**
- * Boot driver for the config-tree `dsh web` shape: holds only what exists
- * independently of (and prior to) cordis — argv facts, the composed patch
- * set, and finally the root ctx.
+ * Boot driver for the config-tree dsh surfaces (web and headless share the
+ * one composition; the surfaces differ only in constructor facts): holds only
+ * what exists independently of (and prior to) cordis — argv facts, the
+ * composed patch set, and finally the root ctx.
  */
 export class AppCLIEntry {
   /** The root context, set by {@link run}. */
@@ -99,13 +105,13 @@ export class AppCLIEntry {
     this.assertBoot()
     const port = this.ctx.get('httpServer')?.port
     /* v8 ignore next -- the sweep above guarantees an ACTIVE webserver row */
-    if (port === undefined) throw new Error('dsh web: httpServer service missing after settled boot')
+    if (port === undefined) throw new Error('dsh: httpServer service missing after settled boot')
     return { ctx: this.ctx, port }
   }
 
   /** Layered .env: ambient > cwd (bin already loaded) > $DSH_HOME (loadEnvFile never overrides). */
   private loadEnvLayers(): void {
-    loadEnv('dsh web', resolveDshHome())
+    loadEnv('dsh', resolveDshHome())
   }
 
   /**
@@ -127,7 +133,7 @@ export class AppCLIEntry {
     for (const [key, value] of Object.entries(this.readProfile())) {
       const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key)
       if (mapping === undefined) {
-        throw new Error(`dsh web: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`)
+        throw new Error(`dsh: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`)
       }
       put(mapping.entryId, mapping.configKey, value)
     }
@@ -142,7 +148,7 @@ export class AppCLIEntry {
 
     this.patches = [...overrides.entries()].map(([id, bag]) => {
       const yml = rows.get(id)
-      if (yml === undefined) throw new Error(`dsh web: patch target row "${id}" not found in ${this.options.configPath}`)
+      if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`)
       return { id, config: { ...(yml.config ?? {}) as Record<string, unknown>, ...bag } }
     })
   }
@@ -173,8 +179,8 @@ export class AppCLIEntry {
    * below catches PENDING fibers (cordis inject waiting has no timeout).
    */
   private assertBoot(): void {
-    installFailLoud('dsh web')
-    assertEntriesLoaded(this.ctx, 'dsh web')
+    installFailLoud('dsh')
+    assertEntriesLoaded(this.ctx, 'dsh')
     const failures: string[] = []
     for (const entry of this.ctx.loader.entries()) {
       if (entry.fiber === undefined || entry.disabled) continue
@@ -188,14 +194,14 @@ export class AppCLIEntry {
       }
     }
     if (failures.length > 0) {
-      throw new Error(`dsh web: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
+      throw new Error(`dsh: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
     }
   }
 
   /** Bypass parse of the shipped yml (id → row) for patch-merge inputs; Loader still reads the file itself. */
   private parseYmlRows(): Map<string, { config?: unknown }> {
     const doc = yaml.load(readFileSync(this.options.configPath, 'utf8'), { schema: includeYamlSchema })
-    if (!Array.isArray(doc)) throw new Error(`dsh web: ${this.options.configPath} is not a top-level entry list`)
+    if (!Array.isArray(doc)) throw new Error(`dsh: ${this.options.configPath} is not a top-level entry list`)
     const rows = new Map<string, { config?: unknown }>()
     for (const row of doc as { id?: string; config?: unknown }[]) {
       if (typeof row.id === 'string') rows.set(row.id, row)
@@ -214,7 +220,7 @@ export class AppCLIEntry {
     }
     const parsed: unknown = JSON.parse(raw)
     if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
-      throw new Error(`dsh web: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`)
+      throw new Error(`dsh: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`)
     }
     return parsed as Record<string, unknown>
   }
@@ -225,7 +231,7 @@ export class AppCLIEntry {
     try {
       return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
     } catch {
-      throw new Error('dsh web: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first')
+      throw new Error('dsh: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first')
     }
   }
 }

+ 22 - 17
apps/cli/src/headless.ts

@@ -1,18 +1,20 @@
 /**
- * `dsh -p "task"` — the headless assembly: startHost + in-process isomorphic
- * injection (InProcessApiClient over the host handler, so the full carrier
- * chain — wire serialization, zod, SSE framing — really runs; this is the
- * protocol's second real consumer). No HTTP server, no port, no dist
- * resolution. Runs one task turn, prints the final assistant text, exits
- * (completed → 0, else 1).
+ * `dsh -p "task"` — headless over the one shared composition: AppCLIEntry
+ * boots the same cordis.yml as `dsh web` (port 0, so parallel runs never
+ * collide), then in-process isomorphic injection (InProcessApiClient over
+ * toFetchHandler(ctx.apiProxy), so the full carrier chain — wire
+ * serialization, zod, SSE framing — really runs). The printed URL opens the
+ * live session in a browser while the task runs. Runs one task turn, prints
+ * the final assistant text, exits (completed → 0, else 1).
  */
 
 import { parseArgs } from 'node:util'
-import { startHost } from '@deepseek-ai/dsh-host-runtime'
-import { InProcessApiClient } from '@deepseek-ai/dsh-host-apiproxy'
+import { fileURLToPath } from 'node:url'
+import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
 import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
 import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
 import type { SessionId } from '@deepseek-ai/dsh-session'
+import { AppCLIEntry } from './app-cli-entry.ts'
 
 /** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */
 interface TurnOutcome {
@@ -78,15 +80,18 @@ export async function runHeadless(argv: string[]): Promise<void> {
   }
 
   // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design).
-  const host = await startHost({
-    boot: {
-      persistenceRoot: './.sessions',
-      workspaceContext: false,
-    },
+  const entry = new AppCLIEntry({
+    configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
+    dev: false,
+    port: 0,
   })
-  const api = new InProcessApiClient(host.handler)
+  const { ctx, port } = await entry.run()
+  const dispose = async (): Promise<void> => { await ctx.fiber.dispose() }
+  // The headless session is web-observable while it runs (same composition).
+  process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`)
+  const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
 
-  const created = await unwrap(await api.sessions.create({}), () => host.dispose())
+  const created = await unwrap(await api.sessions.create({}), dispose)
 
   // Open the stream before prompting so no frame is lost — kept in this order
   // even though in-process delivery has no race, so the code survives a move
@@ -99,11 +104,11 @@ export async function runHeadless(argv: string[]): Promise<void> {
     sessionId: created.sessionId,
     mode: 'queue',
     content: [{ type: 'text', text: task }],
-  }), () => host.dispose())
+  }), dispose)
 
   const outcome = await done
   process.stdout.write(outcome.text + '\n')
   abort.abort()
-  await host.dispose()
+  await dispose()
   process.exit(outcome.reason === 'completed' ? 0 : 1)
 }

+ 0 - 3
apps/cli/tsconfig.json

@@ -14,9 +14,6 @@
     {
       "path": "../../packages/host/apiproxy"
     },
-    {
-      "path": "../../packages/host/runtime"
-    },
     {
       "path": "../../packages/host/webserver"
     },

+ 0 - 1
docs/config-catalog.md

@@ -2071,7 +2071,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
 - `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts))
 - `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts))
 - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts))
-- `@deepseek-ai/dsh-host-runtime` ([`packages/host/runtime/src/index.ts`](../packages/host/runtime/src/index.ts))
 - `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts))
 - `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts))
 - `@deepseek-ai/dsh-paths` ([`packages/util/paths/src/index.ts`](../packages/util/paths/src/index.ts))

+ 0 - 3
docs/module-graph.md

@@ -171,7 +171,6 @@ flowchart TD
   end
   subgraph group_host["packages/host"]
     pkg_host_apiproxy["host-apiproxy"]
-    pkg_host_runtime["host-runtime"]
     pkg_host_webserver["host-webserver"]
   end
   subgraph group_lsp["packages/lsp"]
@@ -238,7 +237,6 @@ flowchart TD
   pkg_code_runtime --> pkg_invariants
   pkg_jsonrpc_demo --> pkg_invariants
   pkg_host_apiproxy --> pkg_invariants
-  pkg_host_runtime --> pkg_invariants
   pkg_host_webserver --> pkg_invariants
   pkg_storage --> pkg_invariants
   pkg_llm --> pkg_brand
@@ -808,7 +806,6 @@ flowchart TD
 | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) |
 | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) |
 | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) |
-| [`host-runtime`](../packages/host/runtime) | `host` | [`invariants`](../packages/support/invariants) |
 | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) |
 | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) |
 | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |

+ 0 - 5
knip.json

@@ -57,11 +57,6 @@
       ]
     },
     "packages/host/webserver": {
-      "project": [
-        "src/**/*.ts"
-      ]
-    },
-    "packages/host/runtime": {
       "entry": [
         "tests/**/*.spec.ts"
       ],

+ 1 - 1
packages/host/apiproxy/README.md

@@ -1,6 +1,6 @@
 # @deepseek-ai/dsh-host-apiproxy
 
-The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The core spine composition lives in `dsh-host-runtime`.
+The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The composition lives in `apps/cli/cordis.yml` (the `api-gateway` row).
 
 ## Contract layer (`/api`)
 

+ 1 - 1
packages/host/apiproxy/src/api-proxy.ts

@@ -172,7 +172,7 @@ async function summarizeCold(persistence: SessionPersistence, meta: SessionHeade
   }
 }
 
-/** Host-level default agent routing (same shape as dsh-host-runtime's HostDefaults, kept structural to avoid a reverse dependency). */
+/** Host-level default agent routing: provider/model from the gateway config, cwd from the host process. */
 export interface ApiProxyDefaults {
   provider: string
   model: string

+ 0 - 0
packages/host/runtime/tests/api-proxy-cold.spec.ts → packages/host/apiproxy/tests/api-proxy-cold.spec.ts


+ 0 - 0
packages/host/runtime/tests/api-proxy-view.spec.ts → packages/host/apiproxy/tests/api-proxy-view.spec.ts


+ 0 - 35
packages/host/runtime/README.md

@@ -1,35 +0,0 @@
-# @deepseek-ai/dsh-host-runtime
-
-Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence and immediate fallback titles, optional first-message model summaries, system prompt, tools, agents, agent loop, workspace instructions, local bash, and the provider-neutral user-interaction service), and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }` (its `api` comes from [`dsh-host-apiproxy`](../apiproxy/README.md)'s `createApiProxy` over that composition).
-
-Which plugins mount and with what defaults is decided only here — shells must not `ctx.plugin` to alter the assembly. `RunningHost.ctx` is a formal seam with exactly two sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`) and headless session-event subscription; consuming clients must not bypass `api` through it.
-
-## Configuration
-
-| Key | Default | Contract |
-|---|---:|---|
-| `persistenceRoot` | (required) | Root directory for JSONL session persistence. |
-| `workspaceContext` | (required) | [`AGENTS.md`/`CLAUDE.md` loader](../../context/workspace-context/README.md) config with an explicit `maxBytes`, or `false` to disable it. |
-| `provider` | `'deepseek'` | Default provider route injected as agentOptions on create/resume and reported by `host.describe`. |
-| `model` | `'deepseek-v4-flash'` | Default model id, same single source as `provider`. |
-| `cwd` | `process.cwd()` | Default project directory for a session whose create request omits `cwd`. |
-| `sessionTitle` | 5 words / 40 fallback bytes / 80 accepted bytes | Deterministic fallback and accepted-title limits. |
-| `sessionTitleLlm` | disabled | `true` enables the 5-word / 10-CJK-character, 4,096-input-byte, 64-output-token, 60-second first-message policy; an explicit config overrides it. An omitted route inherits the logged main-request provider and model. |
-
-## ApiProxy implementation notes
-
-Unary methods take the narrow `RpcRequest<P>` and echo `request.rpcId`; a prompt's rpcId rides `MessageSource` into the `user/message` event so clients can promote optimistic echoes. `history`/`prompt` on a cold session implicitly resume it, deduplicating concurrent calls through an in-flight table; `history` paginates backwards on message boundaries (never mid-message). The mux stream replays a `session/subscribed` baseline per attached session and every still-pending question with its original rpcId. Question responses, including blank per-item answers, are validated against the owning session and exact request before an atomic first-wins claim; answer, whole-request cancellation, owner abort, and provider disposal broadcast `question/resolved`. The host stream carries session lifecycle, running flips, and `agent/error` as the only outlet for live failures with no turn position.
-
-## Model Experience
-
-Indirectly, through the non-blocking first-message title request owned by [`dsh-session-title-llm`](../../session-title/session-title-llm/README.md) when `sessionTitleLlm` is enabled, the provider/model defaults injected into created and resumed agents, the other model-facing plugins `bootHost` mounts, and the logged [workspace-instruction prefix](../../context/workspace-context/README.md#prompt-shape) when `workspaceContext` is enabled.
-
-#### KV Cache effect
-
-No main-request invalidation; when enabled, the auxiliary title request has its own cache behavior and leaves the conversation prefix unchanged.
-
-## Known Limitations and Deferred Work
-
-- **Question waits are process-memory state** — browser reconnects recover them, but a host process restart aborts the owning tool call instead of restoring the wait from persistence.
-- **`host.describe.version` is a placeholder** — it does not yet report the `apps/cli` package version.
-- **The assembly is fixed** — per-deployment plugin selection (user profile, log sinks, alternative persistence) has a documented home here but no configuration surface yet.

+ 0 - 77
packages/host/runtime/package.json

@@ -1,77 +0,0 @@
-{
-  "name": "@deepseek-ai/dsh-host-runtime",
-  "description": "Host runtime assembly for dsh: bootHost composes the core spine, createApiProxy implements the contract, startHost is the one-step shell seam",
-  "version": "0.0.1",
-  "private": true,
-  "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"
-    },
-    "./src/*": "./src/*",
-    "./package.json": "./package.json"
-  },
-  "files": [
-    "lib/index.js",
-    "lib/invariant.js",
-    "lib/types/**/*.d.ts",
-    "lib/types/**/*.d.ts.map",
-    "src"
-  ],
-  "license": "BSD-3-Clause",
-  "dependencies": {
-    "@cordisjs/plugin-loader": "workspace:^",
-    "@cordisjs/plugin-timer": "workspace:^",
-    "@deepseek-ai/dsh-agent": "workspace:^",
-    "@deepseek-ai/dsh-agent-loop": "workspace:^",
-    "@deepseek-ai/dsh-bash-local": "workspace:^",
-    "@deepseek-ai/dsh-compact-basic": "workspace:^",
-    "@deepseek-ai/dsh-fs-local": "workspace:^",
-    "@deepseek-ai/dsh-fs-policy": "workspace:^",
-    "@deepseek-ai/dsh-host-apiproxy": "workspace:^",
-    "@deepseek-ai/dsh-llm": "workspace:^",
-    "@deepseek-ai/dsh-llm-deepseek": "workspace:^",
-    "@deepseek-ai/dsh-session": "workspace:^",
-    "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
-    "@deepseek-ai/dsh-session-title": "workspace:^",
-    "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
-    "@deepseek-ai/dsh-skill": "workspace:^",
-    "@deepseek-ai/dsh-skill-local": "workspace:^",
-    "@deepseek-ai/dsh-spill-local": "workspace:^",
-    "@deepseek-ai/dsh-spill-policy": "workspace:^",
-    "@deepseek-ai/dsh-subagent": "workspace:^",
-    "@deepseek-ai/dsh-subagent-fork": "workspace:^",
-    "@deepseek-ai/dsh-subagent-spawn": "workspace:^",
-    "@deepseek-ai/dsh-system-prompt": "workspace:^",
-    "@deepseek-ai/dsh-tasks": "workspace:^",
-    "@deepseek-ai/dsh-timeout-policy": "workspace:^",
-    "@deepseek-ai/dsh-token-meter": "workspace:^",
-    "@deepseek-ai/dsh-tool-bash": "workspace:^",
-    "@deepseek-ai/dsh-tool-fs": "workspace:^",
-    "@deepseek-ai/dsh-tool-fs-search": "workspace:^",
-    "@deepseek-ai/dsh-tool-skill": "workspace:^",
-    "@deepseek-ai/dsh-tool-subagent": "workspace:^",
-    "@deepseek-ai/dsh-tool-tasks": "workspace:^",
-    "@deepseek-ai/dsh-tool-todo": "workspace:^",
-    "@deepseek-ai/dsh-tool-workflow": "workspace:^",
-    "@deepseek-ai/dsh-tools": "workspace:^",
-    "@deepseek-ai/dsh-user-interaction": "workspace:^",
-    "@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
-    "@deepseek-ai/dsh-workspace-context": "workspace:^"
-  },
-  "peerDependencies": {
-    "cordis": "^4.0.0-rc.7",
-    "@deepseek-ai/dsh-invariants": "^0.0.1"
-  },
-  "devDependencies": {
-    "cordis": "^4.0.0-rc.7",
-    "@deepseek-ai/dsh-invariants": "workspace:^"
-  }
-}

+ 0 - 171
packages/host/runtime/src/boot.ts

@@ -1,171 +0,0 @@
-/**
- * Core spine composition for the dsh host: mounts the harness core plugins
- * one by one (each awaited so a load failure surfaces deterministically at
- * boot, unlike bundle plugins whose children mount unawaited).
- */
-
-import { Context } from 'cordis'
-import Timer from '@cordisjs/plugin-timer'
-import LlmService from '@deepseek-ai/dsh-llm'
-import SessionStore from '@deepseek-ai/dsh-session'
-import SessionTitleService, { type Config as SessionTitleConfig } from '@deepseek-ai/dsh-session-title'
-import * as SessionTitleFirstMessageLlm from '@deepseek-ai/dsh-session-title-first-message-llm'
-import type { Config as SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-first-message-llm'
-import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
-import ToolRegistry from '@deepseek-ai/dsh-tools'
-import AgentRegistry from '@deepseek-ai/dsh-agent'
-import TaskService from '@deepseek-ai/dsh-tasks'
-import AgentLoop from '@deepseek-ai/dsh-agent-loop'
-import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
-import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
-import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
-import * as toolBash from '@deepseek-ai/dsh-tool-bash'
-import * as toolTodo from '@deepseek-ai/dsh-tool-todo'
-import * as toolTasks from '@deepseek-ai/dsh-tool-tasks'
-import FsLocal from '@deepseek-ai/dsh-fs-local'
-import * as fsPolicy from '@deepseek-ai/dsh-fs-policy'
-import * as toolFs from '@deepseek-ai/dsh-tool-fs'
-import * as toolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
-import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
-import SkillService from '@deepseek-ai/dsh-skill'
-import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
-import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
-import TokenMeter from '@deepseek-ai/dsh-token-meter'
-import CompactBasic from '@deepseek-ai/dsh-compact-basic'
-import SubagentService from '@deepseek-ai/dsh-subagent'
-import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
-import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork'
-import * as toolSubagent from '@deepseek-ai/dsh-tool-subagent'
-import WorkflowWorkerthread from '@deepseek-ai/dsh-workflow-workerthread'
-import * as toolWorkflow from '@deepseek-ai/dsh-tool-workflow'
-import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
-import SpillLocal from '@deepseek-ai/dsh-spill-local'
-import * as spillPolicy from '@deepseek-ai/dsh-spill-policy'
-import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
-
-/** Default deterministic title policy for sessions created through the host. */
-const DEFAULT_SESSION_TITLE_CONFIG: SessionTitleConfig = {
-  fallbackMaxWords: 5,
-  fallbackMaxBytes: 40,
-  maxTitleBytes: 80,
-}
-
-/** Default first-message model-title policy for sessions created through the host. */
-const DEFAULT_SESSION_TITLE_LLM_CONFIG: SessionTitleLlmConfig = {
-  targetWords: 5,
-  targetCjkCharacters: 10,
-  maxInputBytes: 4_096,
-  maxOutputTokens: 64,
-  timeoutMs: 60_000,
-}
-
-/** Options for bootHost — the assembly-layer composition knobs. */
-export interface BootHostOptions {
-  /** Root directory for JSONL session persistence. */
-  persistenceRoot: string
-  /** Workspace-instruction byte budget/config, or false to disable AGENTS.md/CLAUDE.md loading. */
-  workspaceContext: workspaceContext.Config | false
-  /** Default provider route for created/resumed agents (defaults to 'deepseek', the only adapter bootHost registers). */
-  provider?: string
-  /** Default model id (defaults to 'deepseek-v4-flash', matching the demos). */
-  model?: string
-  /** Deterministic fallback-title limits. */
-  sessionTitle?: SessionTitleConfig
-  /** Opt-in first-message model-title policy; `true` selects host defaults and an explicit config overrides them. */
-  sessionTitleLlm?: true | SessionTitleLlmConfig
-  /**
-   * Default project directory for sessions created without an explicit cwd
-   * (defaults to the host process working directory). A session's cwd is its
-   * project path — a per-session choice, not a host property; this option only
-   * supplies the value used when the creator does not choose one.
-   */
-  cwd?: string
-}
-
-/** Host-level default agent routing: the single source injected on create and reported by host.describe. */
-export interface HostDefaults {
-  provider: string
-  model: string
-  /** Default project directory for new sessions whose create request carries no cwd. */
-  cwd: string
-}
-
-/** Booted host handle: composed root context + resolved defaults + disposer. */
-export interface HostHandle {
-  /** Root context with the full plugin assembly mounted. */
-  ctx: Context
-  /** Resolved default agent routing (options ?? built-in fallbacks). */
-  defaults: HostDefaults
-  /** Tear down the whole plugin tree. */
-  dispose(): Promise<void>
-}
-
-/**
- * Compose the harness host plugin assembly (the one place deciding which plugins mount and
- * with what defaults — shells must not alter the assembly).
- * @param options - persistence, workspace instructions, and optional default routing.
- * @returns the booted handle (ctx + defaults + dispose).
- */
-export async function bootHost(options: BootHostOptions): Promise<HostHandle> {
-  const defaults: HostDefaults = {
-    provider: options.provider ?? 'deepseek',
-    model: options.model ?? 'deepseek-v4-flash',
-    cwd: options.cwd ?? process.cwd(),
-  }
-  const ctx = new Context()
-  await ctx.plugin(Timer)
-  await ctx.plugin(LlmService)
-  await ctx.plugin(SessionStore)
-  await ctx.plugin(SessionTitleService, options.sessionTitle ?? DEFAULT_SESSION_TITLE_CONFIG)
-  if (options.sessionTitleLlm !== undefined) {
-    await ctx.plugin(
-      SessionTitleFirstMessageLlm,
-      options.sessionTitleLlm === true ? DEFAULT_SESSION_TITLE_LLM_CONFIG : options.sessionTitleLlm,
-    )
-  }
-  await ctx.plugin(SystemPrompt, { persona: '' })
-  await ctx.plugin(ToolRegistry)
-  await ctx.plugin(UserInteractionService)
-  await ctx.plugin(AgentRegistry)
-  await ctx.plugin(TaskService)
-  await ctx.plugin(AgentLoop, { agents: [] })
-  await ctx.plugin(LlmDeepSeek, {})
-  await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot })
-  await ctx.plugin(LocalBashExecutor, {})
-  // Tool suite mirroring the demo:repl composition (repl-agent/cordis.yml +
-  // the agent-spine bundle) so web sessions get the same coding-agent tool
-  // face; deviations are noted inline.
-  await ctx.plugin(toolBash, {})
-  await ctx.plugin(toolTodo)
-  await ctx.plugin(toolTasks, {})
-  // fs paths resolve against the host default project rather than the raw
-  // process cwd — the same source create() injects into session.cwd.
-  await ctx.plugin(FsLocal, { cwd: defaults.cwd })
-  await ctx.plugin(fsPolicy)
-  await ctx.plugin(toolFs, {})
-  await ctx.plugin(toolFsSearch, {})
-  if (options.workspaceContext !== false) {
-    await ctx.plugin(workspaceContext, options.workspaceContext)
-  }
-  // Skill stack with the demo default dshHome (~/.dsh via resolveDshHome).
-  await ctx.plugin(SkillService, {})
-  await ctx.plugin(SkillLocal, {})
-  await ctx.plugin(toolSkill, {})
-  // Request pressure + compaction (service-wide defaults, as in repl-agent).
-  await ctx.plugin(TokenMeter)
-  await ctx.plugin(CompactBasic)
-  // Subagent spawn/fork backends and their two model-facing tool instances.
-  await ctx.plugin(SubagentService)
-  await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
-  await ctx.plugin(SubagentFork, { providerName: 'fork' })
-  await ctx.plugin(toolSubagent, { provider: 'spawn', toolName: 'subagent' })
-  await ctx.plugin(toolSubagent, { provider: 'fork', toolName: 'subagent_fork' })
-  await ctx.plugin(WorkflowWorkerthread, { provider: 'spawn' })
-  await ctx.plugin(toolWorkflow, {})
-  // Declared per-tool timeouts become enforced deadlines.
-  await ctx.plugin(timeoutPolicy)
-  // Oversized tool output spills to session-scoped files (repl-agent budget).
-  await ctx.plugin(SpillLocal, {})
-  await ctx.plugin(spillPolicy, { maxInlineBytes: 50000 })
-  return { ctx, defaults, dispose: () => ctx.fiber.dispose() }
-}

+ 0 - 11
packages/host/runtime/src/index.ts

@@ -1,11 +0,0 @@
-/**
- * @deepseek-ai/dsh-host-runtime — host runtime assembly layer: the core spine
- * composition (bootHost) and the one-step shell seam (startHost). The ApiProxy
- * implementation lives in @deepseek-ai/dsh-host-apiproxy. Host-level
- * configuration (defaults, persistenceRoot, future user profile) lives here.
- */
-
-export { bootHost } from './boot.ts'
-export type { BootHostOptions, HostDefaults, HostHandle } from './boot.ts'
-export { startHost } from './start.ts'
-export type { StartHostOptions, RunningHost } from './start.ts'

+ 0 - 31
packages/host/runtime/src/invariant.ts

@@ -1,31 +0,0 @@
-/**
- * Package-owned invariant companion for `@deepseek-ai/dsh-host-runtime`.
- * @module @deepseek-ai/dsh-host-runtime/invariant
- */
-
-/* jscpd:ignore-start */
-import type { Context } from 'cordis'
-import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
-
-const PACKAGE_NAME = '@deepseek-ai/dsh-host-runtime'
-
-/** Cordis companion plugin name. */
-export const name = 'host-runtime-invariant'
-/** Service required before the companion can reserve package ownership. */
-export const inject = ['invariants']
-
-/**
- * No runtime invariant: this assembly layer only composes plugins owned
- * elsewhere; the event/data relations it touches (session events, agent
- * lifecycle, wire frames) are asserted by their owning packages' companions.
- */
-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))
-/* jscpd:ignore-end */

+ 0 - 54
packages/host/runtime/src/start.ts

@@ -1,54 +0,0 @@
-/**
- * One-step host startup seam: boot core → assemble ApiProxy → assemble the
- * fetch handler. The returned RunningHost is shell-agnostic — node:http
- * (dsh web), in-process injection (dsh -p, tests), an IPC bridge (future
- * Electron sidecar), and automation transports all consume the same shape.
- */
-
-import type { Context } from 'cordis'
-import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
-import { createApiProxy, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
-import { bootHost } from './boot.ts'
-import type { BootHostOptions, HostDefaults } from './boot.ts'
-
-/** Options for startHost. */
-export interface StartHostOptions {
-  /**
-   * Passed through to bootHost verbatim. Future host-level knobs (profile,
-   * log sink — any output added to the assembly MUST be switchable off here)
-   * land as additive fields.
-   */
-  boot: BootHostOptions
-}
-
-/** Running host handle: the contract impl plus its fetch carrier and root ctx. */
-export interface RunningHost {
-  /** Contract implementation (direct calls for in-process consumers; the input of an IPC adapter). */
-  api: ApiProxy
-  /** WHATWG-fetch-shaped carrier (web shell bridges it to node:http; host-side endpoint of an IPC bridge). */
-  handler: { fetch: typeof fetch }
-  /** Host-level default routing (describe and every shell share this single source). */
-  defaults: HostDefaults
-  /**
-   * Root context — a formal seam, not an escape hatch: (1) the mount point for
-   * automation transports; (2) headless session-event subscription. Discipline: consuming clients must
-   * not bypass `api` through ctx; shells must not ctx.plugin to alter the
-   * assembly (mounting a front door is the shell's own shape, not an assembly change).
-   */
-  ctx: Context
-  /** Single shutdown exit (ctx.fiber.dispose()). Idempotent: a second call returns the same promise. */
-  dispose(): Promise<void>
-}
-
-/**
- * Boot the host and assemble its consumption surfaces in one step.
- * @param options - boot passthrough (see StartHostOptions).
- * @returns the running host handle shared by every shell shape.
- */
-export async function startHost(options: StartHostOptions): Promise<RunningHost> {
-  const host = await bootHost(options.boot)
-  const api = createApiProxy(host.ctx, host.defaults)
-  const handler = toFetchHandler(api)
-  let disposing: Promise<void> | undefined
-  return { api, handler, defaults: host.defaults, ctx: host.ctx, dispose: () => (disposing ??= host.dispose()) }
-}

+ 0 - 792
packages/host/runtime/tests/host-runtime.spec.ts

@@ -1,792 +0,0 @@
-import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import type { Context } from 'cordis'
-import type { Agent } from '@deepseek-ai/dsh-agent'
-import { agentEvents } from '@deepseek-ai/dsh-agent'
-import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
-import { LlmAdapter } from '@deepseek-ai/dsh-llm'
-import type { SessionId } from '@deepseek-ai/dsh-session'
-import type { Config as SessionTitleConfig } from '@deepseek-ai/dsh-session-title'
-import type { Config as SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-first-message-llm'
-import type { HostFrame, MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
-import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
-import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
-import { bootHost, startHost, type HostHandle, type RunningHost } from '../src/index.ts'
-
-/** Scripted adapter: each model call consumes the next chunk list; 'hang' streams then waits for abort. */
-class ScriptedAdapter extends LlmAdapter {
-  readonly requests: GenerateOptions[] = []
-
-  constructor(private script: (StreamChunk[] | 'hang')[]) {
-    super()
-  }
-
-  async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
-    if ((options.tools?.length ?? 0) === 0) {
-      yield * textResponse('Durable append-only session titles')
-      return
-    }
-    this.requests.push(options)
-    const entry = this.script.shift()
-    if (!entry) throw new Error('ScriptedAdapter: script exhausted')
-    if (entry === 'hang') {
-      yield { type: 'block-start', index: 0, blockType: 'text' }
-      await new Promise<void>((_resolve, reject) => {
-        options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
-      })
-      return
-    }
-    yield * entry
-  }
-}
-
-function textResponse(text: string): StreamChunk[] {
-  return [
-    { type: 'block-start', index: 0, blockType: 'text' },
-    { type: 'text-delta', index: 0, text },
-    { type: 'block-end', index: 0, block: { type: 'text', text } },
-    { type: 'usage', usage: { inputTokens: 10, outputTokens: text.length } },
-    { type: 'finish', reason: { kind: 'stop' } },
-  ]
-}
-
-function request<P>(payload: P): RpcRequest<P> {
-  return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
-}
-let nextRpc = 1
-
-function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
-  return new Promise((resolve) => {
-    const dispose = ctx.on('agent/status', (subject: Agent, status: string) => {
-      if (subject === agent && status === 'idle') {
-        dispose()
-        resolve()
-      }
-    })
-  })
-}
-
-function expectOk<T>(response: RpcResponse<T>): T {
-  expect(response.result.ok).toBe(true)
-  if (!response.result.ok) throw new Error('unreachable')
-  return response.result.value
-}
-
-async function nextMux(iterator: AsyncIterator<RpcRequest<MuxFrame>>): Promise<RpcRequest<MuxFrame>> {
-  const next = await iterator.next()
-  if (next.done === true) throw new Error('mux ended before the expected frame')
-  return next.value
-}
-
-/** Durably append a title event without mounting title-generation policy. */
-function appendTitle(ctx: Context, agent: Agent, title: string) {
-  return ctx.sessions.appendOutOfBand(agent.session, 'session/title', {
-    title,
-    messageSeqs: [1],
-    source: { kind: 'fallback' },
-  }, { kind: 'session-title' })
-}
-
-let host: RunningHost | undefined
-
-beforeEach(() => {
-  vi.stubEnv('DEEPSEEK_API_KEY', 'spec-placeholder-key')
-})
-
-afterEach(async () => {
-  await host?.dispose()
-  host = undefined
-  vi.unstubAllEnvs()
-})
-
-async function boot(
-  script: (StreamChunk[] | 'hang')[] = [],
-  sessionTitle?: SessionTitleConfig,
-  sessionTitleLlm?: true | SessionTitleLlmConfig,
-): Promise<RunningHost> {
-  host = await startHost({
-    boot: {
-      persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-runtime-')),
-      workspaceContext: false,
-      provider: 'scripted',
-      model: 'test-model',
-      ...(sessionTitle === undefined ? {} : { sessionTitle }),
-      ...(sessionTitleLlm === undefined ? {} : { sessionTitleLlm }),
-    },
-  })
-  host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter(script))
-  return host
-}
-
-describe('bootHost / startHost', () => {
-  it('falls back to the deepseek defaults and disposes idempotently', async () => {
-    const handle: HostHandle = await bootHost({
-      persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-')),
-      workspaceContext: false,
-    })
-    expect(handle.defaults).toMatchObject({ provider: 'deepseek', model: 'deepseek-v4-flash' })
-    expect(typeof handle.defaults.cwd).toBe('string')
-    await handle.dispose()
-  })
-
-  it('uses the JSONL backend compressed default', async () => {
-    const handle: HostHandle = await bootHost({
-      persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-zstd-')),
-      workspaceContext: false,
-    })
-    const session = handle.ctx.sessions.create()
-    expect(handle.ctx.sessionPersistence.locate(session.header)?.path).toMatch(/\.jsonl\.zstd$/)
-    await handle.dispose()
-  })
-
-  it('startHost assembles api + handler over the same defaults and dedupes dispose', async () => {
-    const running = await boot()
-    expect(running.defaults).toMatchObject({ provider: 'scripted', model: 'test-model' })
-    const body = JSON.stringify({ type: 'client-request', rpcId: 'r-h', method: 'host.describe', payload: {} })
-    const response = await running.handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', body }))
-    const parsed = await response.json() as { result: { ok: boolean; value: { provider: string } } }
-    expect(parsed.result.value.provider).toBe('scripted')
-    const first = running.dispose()
-    expect(running.dispose()).toBe(first)
-    await first
-    host = undefined
-  })
-
-  it('routes workspace instructions through the assembled agent request prefix', async () => {
-    const workspace = mkdtempSync(join(tmpdir(), 'dsh-host-workspace-'))
-    mkdirSync(join(workspace, '.git'))
-    writeFileSync(join(workspace, 'AGENTS.md'), 'host-workspace-context-probe\n')
-    const adapter = new ScriptedAdapter([textResponse('done')])
-    host = await startHost({
-      boot: {
-        persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-workspace-sessions-')),
-        workspaceContext: { dshHome: join(workspace, '.dsh'), maxBytes: 65_536 },
-        provider: 'scripted',
-        model: 'test-model',
-        cwd: workspace,
-      },
-    })
-    host.ctx.llm.registerAdapter(['scripted'], adapter)
-    const { sessionId } = expectOk(await host.api.sessions.create(request({})))
-    const agent = host.ctx.agents.get(sessionId) as Agent
-    const idle = waitForIdle(host.ctx, agent)
-
-    expectOk(await host.api.sessions.prompt(request({
-      sessionId,
-      mode: 'queue' as const,
-      content: [{ type: 'text' as const, text: 'go' }],
-    })))
-    await idle
-
-    const requestText = adapter.requests[0]?.messages
-      .flatMap(message => message.content)
-      .filter(block => block.type === 'text')
-      .map(block => block.text)
-      .join('\n') ?? ''
-    expect(requestText).toContain('Instructions from: AGENTS.md')
-    expect(requestText).toContain('host-workspace-context-probe')
-  })
-
-  it('keeps model title generation disabled when sessionTitleLlm is omitted', async () => {
-    const running = await boot([textResponse('pong')])
-    const { api, ctx } = running
-    const { sessionId } = expectOk(await api.sessions.create(request({})))
-    const agent = ctx.agents.get(sessionId) as Agent
-    const idle = waitForIdle(ctx, agent)
-    expectOk(await api.sessions.prompt(request({
-      sessionId,
-      mode: 'queue' as const,
-      content: [{ type: 'text' as const, text: 'Explain durable session titles.' }],
-    })))
-    await idle
-
-    expect((await ctx.sessionTitle.refresh(agent.session))?.source).toEqual({ kind: 'fallback' })
-    expect(agent.session.events.some(event => event.type === 'session/title-llm-request')).toBe(false)
-  })
-})
-
-describe('host.describe', () => {
-  it('reports version, cwd, defaults, and the attached count', async () => {
-    const { api } = await boot()
-    const value = expectOk(await api.host.describe(request({})))
-    expect(value).toMatchObject({ version: '0.0.1', cwd: process.cwd(), provider: 'scripted', model: 'test-model', attachedSessions: 0 })
-  })
-})
-
-describe('sessions.create / list', () => {
-  it('creates a session (echoing the request rpcId) and lists it newest-first', async () => {
-    const { api } = await boot()
-    const created = await api.sessions.create(request({ cwd: '/tmp' }))
-    const { sessionId } = expectOk(created)
-    expect(created.rpcId).toMatch(/^req-/)
-    const second = expectOk(await api.sessions.create(request({}))).sessionId
-
-    const { items } = expectOk(await api.sessions.list(request({})))
-    expect(items.map(item => item.sessionId)).toContain(sessionId)
-    expect(items.map(item => item.sessionId)).toContain(second)
-    const first = items.find(item => item.sessionId === sessionId)
-    expect(first?.cwd).toBe('/tmp')
-    expect(first?.running).toBe(false)
-    expect(first?.parentSessionId).toBeUndefined()
-  })
-
-  it('ensures a missing project directory before minting the session', async () => {
-    const { api } = await boot()
-    const root = mkdtempSync(join(tmpdir(), 'dsh-host-create-cwd-'))
-    const cwd = join(root, 'nested', 'workspace')
-    expect(existsSync(cwd)).toBe(false)
-    const { sessionId } = expectOk(await api.sessions.create(request({ cwd })))
-    expect(existsSync(cwd)).toBe(true)
-    const { items } = expectOk(await api.sessions.list(request({})))
-    expect(items.find(item => item.sessionId === sessionId)?.cwd).toBe(cwd)
-  })
-
-  it('fails loud when the project directory cannot be created', async () => {
-    const { api } = await boot()
-    const root = mkdtempSync(join(tmpdir(), 'dsh-host-create-cwd-fail-'))
-    const blocker = join(root, 'file-not-dir')
-    writeFileSync(blocker, 'x')
-    const response = await api.sessions.create(request({ cwd: join(blocker, 'child') }))
-    expect(response.result.ok).toBe(false)
-    if (response.result.ok) throw new Error('expected mkdir failure')
-    expect(response.result.error.code).toBe('internal')
-    expect(response.result.error.message).toMatch(/failed to ensure project directory/)
-  })
-})
-
-describe('sessions.prompt / cancel', () => {
-  it.each([
-    { name: 'host default', config: true, target: '5 words', maxTokens: 64 },
-    {
-      name: 'configured policy',
-      config: {
-        targetWords: 3,
-        targetCjkCharacters: 8,
-        maxInputBytes: 2_048,
-        maxOutputTokens: 24,
-        timeoutMs: 2_000,
-      },
-      target: '3 words',
-      maxTokens: 24,
-    },
-  ] satisfies {
-    name: string
-    config: true | SessionTitleLlmConfig
-    target: string
-    maxTokens: number
-  }[])('replaces the fallback with a model-backed first-message title using the $name', async ({ config, target, maxTokens }) => {
-    const modelTitle = 'Durable append-only session titles'
-    const running = await boot([textResponse('pong')], undefined, config)
-    const { api, ctx } = running
-    const { sessionId } = expectOk(await api.sessions.create(request({})))
-    const agent = ctx.agents.get(sessionId) as Agent
-    const idle = waitForIdle(ctx, agent)
-    expectOk(await api.sessions.prompt(request({
-      sessionId,
-      mode: 'queue' as const,
-      content: [{ type: 'text' as const, text: 'Explain why append-only logs make session titles durable.' }],
-    })))
-    await idle
-
-    await vi.waitFor(() => {
-      expect(agent.session.events.filter(event => event.type === 'session/title').map(event => event.data))
-        .toEqual([
-          {
-            title: 'Explain why append-only logs make',
-            messageSeqs: [1],
-            source: { kind: 'fallback' },
-          },
-          {
-            title: modelTitle,
-            messageSeqs: [1],
-            source: {
-              kind: 'provider',
-              provider: 'session-title-first-message-llm',
-              model: { provider: 'scripted', model: 'test-model' },
-            },
-          },
-        ])
-    })
-    const titleRequest = agent.session.events.find(event => event.type === 'session/title-llm-request')
-    expect(titleRequest?.data.system).toContain(target)
-    expect(titleRequest?.data.maxTokens).toBe(maxTokens)
-  })
-
-  it.each([
-    { name: 'host default', config: undefined, expected: 'Show the Web UI durable' },
-    {
-      name: 'configured limit',
-      config: { fallbackMaxWords: 2, fallbackMaxBytes: 40, maxTitleBytes: 80 },
-      expected: 'Show the',
-    },
-  ] satisfies { name: string; config: SessionTitleConfig | undefined; expected: string }[])(
-    'logs a durable fallback title with the $name',
-    async ({ config, expected }) => {
-      const running = await boot([textResponse('pong')], config)
-      const { api, ctx } = running
-      const { sessionId } = expectOk(await api.sessions.create(request({})))
-      const agent = ctx.agents.get(sessionId) as Agent
-      const idle = waitForIdle(ctx, agent)
-      expectOk(await api.sessions.prompt(request({
-        sessionId,
-        mode: 'queue' as const,
-        content: [{ type: 'text' as const, text: 'Show the Web UI durable session title' }],
-      })))
-      await idle
-
-      const title = agent.session.events.find(event => event.type === 'session/title')
-      expect(title?.data).toEqual({
-        title: expected,
-        messageSeqs: [1],
-        source: { kind: 'fallback' },
-      })
-    },
-  )
-
-  it('queues a prompt whose rpcId rides into user/message, then the reply lands', async () => {
-    const running = await boot([textResponse('pong')])
-    const { api, ctx } = running
-    const { sessionId } = expectOk(await api.sessions.create(request({})))
-    const agent = ctx.agents.get(sessionId)
-    expect(agent).toBeDefined()
-    const idle = waitForIdle(ctx, agent as Agent)
-    const promptRequest = request({ sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'ping' }] })
-    expectOk(await api.sessions.prompt(promptRequest))
-    await idle
-
-    const value = expectOk(await api.sessions.history(request({ sessionId })))
-    const events = value.events.map(entry => entry.event)
-    const userEvent = events.find(event => event.type === 'user/message') as
-      | { data: { source?: { rpcId?: string } } } | undefined
-    expect(userEvent?.data.source?.rpcId).toBe(promptRequest.rpcId)
-    const reply = events.find(event => event.type === 'assistant/message')
-    expect(reply).toBeDefined()
-  })
-
-  it('steer on an idle agent falls through to send', async () => {
-    const running = await boot([textResponse('steered')])
-    const { api, ctx } = running
-    const { sessionId } = expectOk(await api.sessions.create(request({})))
-    const idle = waitForIdle(ctx, ctx.agents.get(sessionId) as Agent)
-    expectOk(await api.sessions.prompt(request({ sessionId, mode: 'steer' as const, content: [{ type: 'text' as const, text: 'now' }] })))
-    await idle
-  })
-
-  it('errors session-not-found on a ghost session', async () => {
-    const { api } = await boot()
-    const response = await api.sessions.prompt(request({ sessionId: 'session-void' as SessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'x' }] }))
-    expect(response.result.ok).toBe(false)
-    if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
-  })
-
-  it('maps a synchronous send throw to agent-busy', async () => {
-    const { api } = await boot()
-    const { sessionId } = expectOk(await api.sessions.create(request({})))
-    const poisoned = [{ type: 'text', text: 'x', bad: () => 1 }] as never
-    const response = await api.sessions.prompt(request({ sessionId, mode: 'queue' as const, content: poisoned }))
-    expect(response.result.ok).toBe(false)
-    if (!response.result.ok) expect(response.result.error.code).toBe('agent-busy')
-  })
-
-  it('cancels an attached agent and rejects an unattached one', async () => {
-    const running = await boot(['hang'])
-    const { api, ctx } = running
-    const { sessionId } = expectOk(await api.sessions.create(request({})))
-    const agent = ctx.agents.get(sessionId) as Agent
-    agent.followup([{ type: 'text', text: 'run forever' }])
-    expectOk(await api.sessions.cancel(request({ sessionId })))
-
-    const missing = await api.sessions.cancel(request({ sessionId: 'session-none' as SessionId }))
-    expect(missing.result.ok).toBe(false)
-    if (!missing.result.ok) expect(missing.result.error.code).toBe('session-not-found')
-  })
-})
-
-describe('sessions.history', () => {
-  it('implicitly resumes a cold session, deduplicating concurrent calls to one attach', async () => {
-    const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-host-resume-'))
-    const first = await startHost({
-      boot: { persistenceRoot, workspaceContext: false, provider: 'scripted', model: 'test-model' },
-    })
-    first.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([textResponse('persisted')]))
-    const { sessionId } = expectOk(await first.api.sessions.create(request({})))
-    const agent = first.ctx.agents.get(sessionId) as Agent
-    const idle = waitForIdle(first.ctx, agent)
-    agent.followup([{ type: 'text', text: 'save me' }])
-    await idle
-    const titleEvent = await appendTitle(first.ctx, agent, 'Persisted title')
-    await first.dispose()
-
-    host = await startHost({
-      boot: { persistenceRoot, workspaceContext: false, provider: 'scripted', model: 'test-model' },
-    })
-    host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([]))
-    expect(host.ctx.agents.get(sessionId)).toBeUndefined()
-    const abort = new AbortController()
-    const mux = host.api.events.mux(request({}), abort.signal)[Symbol.asyncIterator]()
-    const [a, b] = await Promise.all([
-      host.api.sessions.history(request({ sessionId })),
-      host.api.sessions.history(request({ sessionId })),
-    ])
-    for (const response of [a, b]) {
-      const value = expectOk(response)
-      expect(value.events.some(entry => entry.event.type === 'assistant/message')).toBe(true)
-    }
-    expect(host.ctx.agents.get(sessionId)).toBeDefined()
-    expect(host.ctx.agents.list()).toHaveLength(1)
-    expect((await nextMux(mux)).payload).toMatchObject({ type: 'session/subscribed', sessionId })
-    expect((await nextMux(mux)).payload).toEqual(expect.objectContaining({
-      type: 'session/title', sessionId, title: 'Persisted title', eventSeq: titleEvent.seq,
-    }))
-    abort.abort()
-  })
-
-  it('errors session-not-found when resume fails, deduplicating concurrent resumes', async () => {
-    const { api } = await boot()
-    const ghost = 'session-ghost' as SessionId
-    const [first, second] = await Promise.all([
-      api.sessions.history(request({ sessionId: ghost })),
-      api.sessions.history(request({ sessionId: ghost })),
-    ])
-    for (const response of [first, second]) {
-      expect(response.result.ok).toBe(false)
-      if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
-    }
-  })
-
-  it('paginates backwards on message boundaries with hasMore', async () => {
-    const running = await boot([textResponse('a1'), textResponse('a2'), textResponse('a3')])
-    const { api, ctx } = running
-    const { sessionId } = expectOk(await api.sessions.create(request({})))
-    const agent = ctx.agents.get(sessionId) as Agent
-    for (const text of ['q1', 'q2', 'q3']) {
-      const idle = waitForIdle(ctx, agent)
-      agent.followup([{ type: 'text', text }])
-      await idle
-    }
-
-    const all = expectOk(await api.sessions.history(request({ sessionId })))
-    expect(all.hasMore).toBe(false)
-    const messageCount = all.events.filter(entry => entry.event.type === 'user/message' || entry.event.type === 'assistant/message').length
-    expect(messageCount).toBe(6)
-
-    const lastPage = expectOk(await api.sessions.history(request({ sessionId, maxMessages: 1 })))
-    expect(lastPage.hasMore).toBe(true)
-    expect(lastPage.events.filter(entry => entry.event.type === 'assistant/message')).toHaveLength(1)
-    expect(lastPage.events.filter(entry => entry.event.type === 'user/message')).toHaveLength(0)
-
-    const firstSeq = lastPage.events[0]?.event.seq as number
-    const olderPage = expectOk(await api.sessions.history(request({ sessionId, beforeSeq: firstSeq, maxMessages: 2 })))
-    expect(olderPage.events.at(-1)?.event.seq).toBeLessThan(firstSeq)
-    expect(olderPage.hasMore).toBe(true)
-    expect(olderPage.events.filter(entry => entry.event.type === 'user/message' || entry.event.type === 'assistant/message').length).toBe(2)
-  })
-})
-
-describe('events streams', () => {
-  it('mux: a pending pull wakes when a frame arrives (waiter path)', async () => {
-    const running = await boot()
-    const { api } = running
-    const ac = new AbortController()
-    const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
-    // no sessions yet: next() must pend on the queue's waiter, not the buffer
-    const pending = stream.next()
-    const { sessionId } = expectOk(await api.sessions.create(request({})))
-    const frame = (await pending).value as RpcRequest<MuxFrame>
-    expect(frame.payload).toMatchObject({ type: 'session/subscribed', sessionId })
-    ac.abort()
-    expect((await stream.next()).done).toBe(true)
-  })
-
-  it('lists fork lineage and announces it on the host stream', async () => {
-    const running = await boot()
-    const { api, ctx } = running
-    const { sessionId: parent } = expectOk(await api.sessions.create(request({})))
-    const ac = new AbortController()
-    const stream = api.events.host(request({}), ac.signal)[Symbol.asyncIterator]()
-    const child = `session-child-${String(Date.now())}` as SessionId
-    const handle = await ctx.agents.create({ sessionId: child, meta: { parentSession: parent }, agentOptions: { provider: 'scripted', model: 'test-model' } })
-    expect(handle.agent.id).toBe(child)
-    const added = (await stream.next()).value as RpcRequest<HostFrame>
-    expect(added.payload).toMatchObject({ type: 'host/session-added', sessionId: child, parentSessionId: parent })
-    const { items } = expectOk(await api.sessions.list(request({})))
-    expect(items.find(item => item.sessionId === child)?.parentSessionId).toBe(parent)
-
-    await handle.dispose()
-    let frame: RpcRequest<HostFrame>
-    do frame = (await stream.next()).value as RpcRequest<HostFrame>
-    while (frame.payload.type !== 'host/session-removed')
-    expect(frame.payload).toMatchObject({ type: 'host/session-removed', sessionId: child })
-    ac.abort()
-  })
-
-  it('mux: emits subscribed baselines, live session events, and new-session subscriptions until abort', async () => {
-    const running = await boot([textResponse('live')])
-    const { api, ctx } = running
-    const { sessionId } = expectOk(await api.sessions.create(request({})))
-
-    const ac = new AbortController()
-    const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
-    const baseline = await stream.next()
-    expect((baseline.value as RpcRequest<MuxFrame>).payload).toMatchObject({ type: 'session/subscribed', sessionId })
-
-    const agent = ctx.agents.get(sessionId) as Agent
-    const idle = waitForIdle(ctx, agent)
-    agent.followup([{ type: 'text', text: 'go' }])
-    await idle
-    const live = await stream.next()
-    expect((live.value as RpcRequest<MuxFrame>).payload.type).toBe('session/event')
-
-    const other = expectOk(await api.sessions.create(request({}))).sessionId
-    let frame: RpcRequest<MuxFrame>
-    do frame = (await stream.next()).value as RpcRequest<MuxFrame>
-    while (!(frame.payload.type === 'session/subscribed' && frame.payload.sessionId === other))
-
-    ac.abort()
-    expect((await stream.next()).done).toBe(true)
-  })
-
-  it('mux: projects durable titles after open baselines and immediately after live raw events', async () => {
-    const running = await boot()
-    const { api, ctx } = running
-    const { sessionId } = expectOk(await api.sessions.create(request({})))
-    const agent = ctx.agents.get(sessionId) as Agent
-    const initial = await appendTitle(ctx, agent, 'Initial title')
-
-    const ac = new AbortController()
-    const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
-    expect((await nextMux(stream)).payload).toMatchObject({ type: 'session/subscribed', sessionId })
-    expect((await nextMux(stream)).payload).toEqual(expect.objectContaining({
-      type: 'session/title', sessionId, title: 'Initial title', eventSeq: initial.seq, updatedAt: initial.time,
-    }))
-
-    const revised = await appendTitle(ctx, agent, 'Revised title')
-    let raw: RpcRequest<MuxFrame>
-    do raw = await nextMux(stream)
-    while (!(raw.payload.type === 'session/event' && raw.payload.event.type === 'session/title'))
-    expect(raw.payload).toMatchObject({ type: 'session/event', sessionId, event: { seq: revised.seq } })
-    expect((await nextMux(stream)).payload).toEqual(expect.objectContaining({
-      type: 'session/title', sessionId, title: 'Revised title', eventSeq: revised.seq, updatedAt: revised.time,
-    }))
-    ac.abort()
-  })
-
-  it('mux: emits no title control for untitled subscriptions', async () => {
-    const { api } = await boot()
-    const first = expectOk(await api.sessions.create(request({}))).sessionId
-    const ac = new AbortController()
-    const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
-    expect((await nextMux(stream)).payload).toMatchObject({ type: 'session/subscribed', sessionId: first })
-
-    const second = expectOk(await api.sessions.create(request({}))).sessionId
-    expect((await nextMux(stream)).payload).toMatchObject({ type: 'session/subscribed', sessionId: second })
-    ac.abort()
-  })
-
-  it('host: session lifecycle, status flips (disposed suppressed), and agent errors', async () => {
-    const running = await boot([textResponse('x')])
-    const { api, ctx } = running
-    const ac = new AbortController()
-    const stream = api.events.host(request({}), ac.signal)[Symbol.asyncIterator]()
-
-    const { sessionId } = expectOk(await api.sessions.create(request({})))
-    const added = await stream.next()
-    expect((added.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-added', sessionId })
-
-    const agent = ctx.agents.get(sessionId) as Agent
-    const idle = waitForIdle(ctx, agent)
-    agent.followup([{ type: 'text', text: 'run' }])
-    await idle
-    const runningFrame = await stream.next()
-    expect((runningFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-status', running: true })
-    const idleFrame = await stream.next()
-    expect((idleFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-status', running: false })
-
-    // Raw ctx.emit lacks the scope carrier the mounted invariants plugin now
-    // enforces; dispatch the way the loop does.
-    agentEvents(ctx, agent).emit('agent/error', 1, 1, new Error('boom'))
-    const errorFrame = await stream.next()
-    expect((errorFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/agent-error', message: 'Error: boom' })
-
-    ac.abort()
-    // Push-after-done: an event landing between abort and generator wind-down
-    // must be dropped silently, not crash the queue.
-    agentEvents(ctx, agent).emit('agent/error', 1, 1, new Error('late'))
-    expect((await stream.next()).done).toBe(true)
-  })
-})
-
-describe('question request / response', () => {
-  const questions = [{
-    id: 'mode', question: 'Choose a mode',
-    options: [
-      { label: 'Fast (Recommended)', description: 'Move quickly.' },
-      { label: 'Careful', description: 'Review first.' },
-    ],
-  }]
-
-  it('waits, replays the same rpcId on reconnect, validates, and resolves first-wins', async () => {
-    const running = await boot()
-    const { api, ctx } = running
-    const { sessionId } = expectOk(await api.sessions.create(request({})))
-    const agent = ctx.agents.get(sessionId) as Agent
-    const ac = new AbortController()
-    const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
-    await stream.next() // subscribed baseline starts the generator and installs the queue
-
-    const answerPromise = ctx.userInteraction.ask({ questions, agent })
-    const requested = (await stream.next()).value as RpcRequest<MuxFrame>
-    expect(requested.payload).toMatchObject({ type: 'question/requested', sessionId, questions })
-
-    const wrongSession = await api.respond({
-      type: 'client-response', rpcId: requested.rpcId,
-      result: {
-        ok: true,
-        value: { sessionId: 'session-other', answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)'] }] } },
-      },
-    })
-    expect(wrongSession).toEqual({ accepted: false, reason: 'bad-response' })
-    const badChoice = await api.respond({
-      type: 'client-response', rpcId: requested.rpcId,
-      result: {
-        ok: true,
-        value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Unknown'] }] } },
-      },
-    })
-    expect(badChoice).toEqual({ accepted: false, reason: 'bad-response' })
-    const invalidResults = [
-      { ok: true as const, value: null },
-      { ok: true as const, value: { sessionId, answer: { answers: [] } } },
-      { ok: true as const, value: { sessionId, answer: { answers: [{ id: 'wrong', selected: ['Fast (Recommended)'] }] } } },
-      { ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)', 'Fast (Recommended)'] }] } } },
-      { ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)', 'Careful'] }] } } },
-      { ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: [], custom: '   ' }] } } },
-      { ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Careful'], custom: 'Other' }] } } },
-      { ok: false as const, error: { code: 'internal' as const, message: 'wrong error', details: {} } },
-    ]
-    for (const result of invalidResults) {
-      expect(await api.respond({
-        type: 'client-response', rpcId: requested.rpcId, result,
-      })).toEqual({ accepted: false, reason: 'bad-response' })
-    }
-
-    const reconnectAbort = new AbortController()
-    const replay = api.events.mux(request({}), reconnectAbort.signal)[Symbol.asyncIterator]()
-    await replay.next()
-    const replayed = (await replay.next()).value as RpcRequest<MuxFrame>
-    expect(replayed.rpcId).toBe(requested.rpcId)
-    expect(replayed.payload).toEqual(requested.payload)
-
-    const response = {
-      type: 'client-response' as const,
-      rpcId: requested.rpcId,
-      result: {
-        ok: true as const,
-        value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)'] }] } },
-      },
-    }
-    const [first, duplicate] = await Promise.all([api.respond(response), api.respond(response)])
-    expect([first, duplicate]).toContainEqual({ accepted: true })
-    expect([first, duplicate]).toContainEqual({ accepted: false, reason: 'not-pending' })
-    await expect(answerPromise).resolves.toEqual({
-      answers: [{ id: 'mode', selected: ['Fast (Recommended)'] }],
-    })
-
-    const resolved = (await stream.next()).value as RpcRequest<MuxFrame>
-    expect(resolved.payload).toMatchObject({
-      type: 'question/resolved', sessionId, questionRpcId: requested.rpcId, outcome: 'answered',
-    })
-    expect(await api.respond(response)).toEqual({ accepted: false, reason: 'not-pending' })
-
-    const customQuestions = [{ id: 'detail', question: 'What else?' }]
-    const customAnswer = ctx.userInteraction.ask({ questions: customQuestions, agent })
-    const customRequested = (await stream.next()).value as RpcRequest<MuxFrame>
-    expect(await api.respond({
-      type: 'client-response', rpcId: customRequested.rpcId,
-      result: {
-        ok: true,
-        value: { sessionId, answer: { answers: [{ id: 'detail', selected: [], custom: 'Keep traces' }] } },
-      },
-    })).toEqual({ accepted: true })
-    await expect(customAnswer).resolves.toEqual({
-      answers: [{ id: 'detail', selected: [], custom: 'Keep traces' }],
-    })
-    expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
-      type: 'question/resolved', questionRpcId: customRequested.rpcId, outcome: 'answered',
-    })
-
-    const blankAnswer = ctx.userInteraction.ask({ questions, agent })
-    const blankRequested = (await stream.next()).value as RpcRequest<MuxFrame>
-    expect(await api.respond({
-      type: 'client-response', rpcId: blankRequested.rpcId,
-      result: {
-        ok: true,
-        value: { sessionId, answer: { answers: [{ id: 'mode', selected: [] }] } },
-      },
-    })).toEqual({ accepted: true })
-    await expect(blankAnswer).resolves.toEqual({
-      answers: [{ id: 'mode', selected: [] }],
-    })
-    expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
-      type: 'question/resolved', questionRpcId: blankRequested.rpcId, outcome: 'answered',
-    })
-    ac.abort()
-    reconnectAbort.abort()
-  })
-
-  it('distinguishes user cancellation from owner abort and rejects late responses', async () => {
-    const running = await boot()
-    const { api, ctx } = running
-    const { sessionId } = expectOk(await api.sessions.create(request({})))
-    const agent = ctx.agents.get(sessionId) as Agent
-    const streamAbort = new AbortController()
-    const stream = api.events.mux(request({}), streamAbort.signal)[Symbol.asyncIterator]()
-    await stream.next()
-
-    const cancelled = ctx.userInteraction.ask({ questions, agent }).catch((error: unknown) => error)
-    const requested = (await stream.next()).value as RpcRequest<MuxFrame>
-    expect(await api.respond({
-      type: 'client-response', rpcId: requested.rpcId,
-      result: { ok: false, error: { code: 'cancelled', message: 'skip', details: {} } },
-    })).toEqual({ accepted: true })
-    await expect(cancelled).resolves.toMatchObject({ code: 'ASK_CANCELLED' })
-    expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
-      type: 'question/resolved', outcome: 'cancelled',
-    })
-
-    const ownerAbort = new AbortController()
-    const aborted = ctx.userInteraction.ask({ questions, agent, signal: ownerAbort.signal })
-      .catch((error: unknown) => error)
-    const abortRequest = (await stream.next()).value as RpcRequest<MuxFrame>
-    ownerAbort.abort()
-    await expect(aborted).resolves.toMatchObject({ code: 'ASK_ABORTED' })
-    expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
-      type: 'question/resolved', questionRpcId: abortRequest.rpcId, outcome: 'cancelled',
-    })
-    expect(await api.respond({
-      type: 'client-response', rpcId: abortRequest.rpcId,
-      result: { ok: false, error: { code: 'cancelled', message: 'late', details: {} } },
-    })).toEqual({ accepted: false, reason: 'not-pending' })
-    streamAbort.abort()
-  })
-
-  it('rejects missing routing and pre-abort, then aborts outstanding waits on disposal', async () => {
-    const running = await boot()
-    const { ctx } = running
-    await expect(ctx.userInteraction.ask({ questions })).rejects.toMatchObject({ code: 'ASK_MISSING_AGENT' })
-    const { sessionId } = expectOk(await running.api.sessions.create(request({})))
-    const agent = ctx.agents.get(sessionId) as Agent
-    const alreadyAborted = new AbortController()
-    alreadyAborted.abort()
-    await expect(ctx.userInteraction.ask({ questions, agent, signal: alreadyAborted.signal }))
-      .rejects.toMatchObject({ code: 'ASK_ABORTED' })
-
-    const outstanding = ctx.userInteraction.ask({ questions, agent })
-    const disposed = running.dispose()
-    host = undefined
-    await expect(outstanding).rejects.toMatchObject({ code: 'ASK_ABORTED' })
-    await disposed
-  })
-})

+ 0 - 132
packages/host/runtime/tsconfig.json

@@ -1,132 +0,0 @@
-{
-  "extends": "../../../tsconfig.base.json",
-  "compilerOptions": {
-    "rootDir": "src",
-    "outDir": "lib/types"
-  },
-  "include": [
-    "src"
-  ],
-  "references": [
-    {
-      "path": "../../../vendor/cordis"
-    },
-    {
-      "path": "../../../vendor/timer"
-    },
-    {
-      "path": "../../llm/llm"
-    },
-    {
-      "path": "../../llm/llm-deepseek"
-    },
-    {
-      "path": "../../core/session"
-    },
-    {
-      "path": "../../session-title/session-title"
-    },
-    {
-      "path": "../../session-title/session-title-first-message-llm"
-    },
-    {
-      "path": "../../core/system-prompt"
-    },
-    {
-      "path": "../../core/tools"
-    },
-    {
-      "path": "../../core/agent"
-    },
-    {
-      "path": "../../tasks/tasks"
-    },
-    {
-      "path": "../../core/agent-loop"
-    },
-    {
-      "path": "../../session-persistence/session-persistence-jsonl"
-    },
-    {
-      "path": "../../bash/bash-local"
-    },
-    {
-      "path": "../../bash/tool-bash"
-    },
-    {
-      "path": "../../compact/compact-basic"
-    },
-    {
-      "path": "../../fs/fs-local"
-    },
-    {
-      "path": "../../fs/fs-policy"
-    },
-    {
-      "path": "../../fs/tool-fs"
-    },
-    {
-      "path": "../../fs/tool-fs-search"
-    },
-    {
-      "path": "../../llm/token-meter"
-    },
-    {
-      "path": "../../skill/skill"
-    },
-    {
-      "path": "../../skill/skill-local"
-    },
-    {
-      "path": "../../skill/tool-skill"
-    },
-    {
-      "path": "../../spill/spill-local"
-    },
-    {
-      "path": "../../spill/spill-policy"
-    },
-    {
-      "path": "../../subagent/subagent"
-    },
-    {
-      "path": "../../subagent/subagent-fork"
-    },
-    {
-      "path": "../../subagent/subagent-spawn"
-    },
-    {
-      "path": "../../subagent/tool-subagent"
-    },
-    {
-      "path": "../../support/invariants"
-    },
-    {
-      "path": "../../tasks/tool-tasks"
-    },
-    {
-      "path": "../../timeout/timeout-policy"
-    },
-    {
-      "path": "../../todo/tool-todo"
-    },
-    {
-      "path": "../../workflow/tool-workflow"
-    },
-    {
-      "path": "../../workflow/workflow-workerthread"
-    },
-    {
-      "path": "../apiproxy"
-    },
-    {
-      "path": "../../../vendor/loader"
-    },
-    {
-      "path": "../../context/workspace-context"
-    },
-    {
-      "path": "../../ui/user-interaction"
-    }
-  ]
-}

+ 168 - 0
packages/host/webserver/tests/webserver.spec.ts

@@ -0,0 +1,168 @@
+/**
+ * REAL-composition coverage: a test-only cordis.yml booted through the
+ * vendored Loader mounts the webserver row, and every assertion observes the
+ * user-visible HTTP surface of the running server (routing precedence, index
+ * taps, static-fallback semantics, per-request error containment, teardown).
+ */
+
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { mkdir } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { afterEach, describe, expect, it } from 'vitest'
+import { Context, FiberState } from 'cordis'
+import Loader from '@cordisjs/plugin-loader'
+import Include from '@cordisjs/plugin-include'
+import HttpServer from '../src/index.ts'
+
+let root: string | undefined
+let context: Context | undefined
+
+afterEach(async () => {
+  await context?.fiber.dispose()
+  context = undefined
+  if (root !== undefined) await rm(root, { recursive: true, force: true })
+  root = undefined
+})
+
+/** Write a dist fixture and a cordis.yml with one webserver row, then boot it through the real Loader. */
+async function loadComposition(port = 0): Promise<Context> {
+  root = await mkdtemp(join(tmpdir(), 'dsh-webserver-loader-'))
+  const dist = join(root, 'dist')
+  await mkdir(dist)
+  const distIndex = join(dist, 'index.html')
+  await writeFile(distIndex, '<head></head><body>shell</body>')
+  await writeFile(join(dist, 'app.js'), 'export {}')
+  const configPath = join(root, 'cordis.yml')
+  await writeFile(configPath, [
+    "- name: '@deepseek-ai/dsh-host-webserver'",
+    '  config:',
+    "    host: '127.0.0.1'",
+    `    port: ${String(port)}`,
+    `    distIndex: '${distIndex}'`,
+    '',
+  ].join('\n'))
+
+  context = new Context()
+  context.baseUrl = pathToFileURL(root).href + '/'
+  await context.plugin(Loader)
+  context.loader.builtins.include = Include
+  const modules = new Map<string, unknown>([
+    ['@deepseek-ai/dsh-host-webserver', HttpServer],
+  ])
+  context.loader.internal = {
+    version: 'v2',
+    async import(specifier: string) {
+      if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
+      return modules.get(specifier)
+    },
+  } as unknown as NonNullable<typeof context.loader.internal>
+  await context.loader.create({
+    name: 'cordis:include',
+    config: { path: pathToFileURL(configPath).href },
+  })
+  await context.loader.await()
+  return context
+}
+
+/** GET (by default) one path against the running server; returns status plus a body prefix. */
+async function request(port: number, path: string, init?: RequestInit): Promise<{ status: number; body: string }> {
+  const response = await fetch(`http://127.0.0.1:${String(port)}${path}`, init)
+  return { status: response.status, body: (await response.text()).slice(0, 80) }
+}
+
+describe('real Loader composition', () => {
+  // Real-Loader composition resolves workspace packages through tsx at test
+  // time; first resolution after the host/client program split is slow enough
+  // to trip the default 5s budget on cold caches.
+  it('serves registered routes, index taps, and the static fallback semantics', { timeout: 60_000 }, async () => {
+    const loaded = await loadComposition()
+    const unloaded = [...loaded.loader.entries()]
+      .filter(entry => entry.fiber === undefined && !entry.disabled)
+      .map(entry => entry.options.name)
+    expect(unloaded).toEqual([])
+
+    const server = loaded.httpServer
+    expect(server).toBeInstanceOf(HttpServer)
+    const port = server.port
+    expect(port).toBeGreaterThan(0)
+
+    // Routing precedence: exact beats prefix, longest prefix wins, a prefix
+    // route answers its own path, and routes own their method handling
+    // (POST reaches a registered prefix; 405 is fallback-only semantics).
+    server.register({ kind: 'exact', path: '/probe', handler: (_req, res) => { res.writeHead(200); res.end('EXACT') } })
+    server.register({ kind: 'prefix', path: '/api', handler: (_req, res) => { res.writeHead(200); res.end('API') } })
+    server.register({ kind: 'prefix', path: '/api/deep', handler: (_req, res) => { res.writeHead(200); res.end('DEEP') } })
+    expect(await request(port, '/probe')).toMatchObject({ status: 200, body: 'EXACT' })
+    expect(await request(port, '/api/anything')).toMatchObject({ status: 200, body: 'API' })
+    expect(await request(port, '/api/deep/leaf')).toMatchObject({ status: 200, body: 'DEEP' })
+    expect(await request(port, '/api')).toMatchObject({ status: 200, body: 'API' })
+    expect(await request(port, '/api/anything', { method: 'POST' })).toMatchObject({ status: 200, body: 'API' })
+
+    // Index taps apply in registration order on `/` and on the SPA fallback;
+    // the disposer removes the transform.
+    const untap = server.tapIndex(html => html.replace('<head>', '<head><script>window.__T__=1</script>'))
+    expect((await request(port, '/')).body).toContain('__T__')
+    expect((await request(port, '/no/such/route')).body).toContain('__T__')
+    untap()
+    expect((await request(port, '/')).body).not.toContain('__T__')
+
+    // Static fallback semantics: real asset served, traversal 403, non-GET/
+    // HEAD without a matching route 405.
+    expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export {}' })
+    expect((await request(port, '/..%2f..%2fetc%2fpasswd')).status).toBe(403)
+    expect((await request(port, '/nowhere', { method: 'POST' })).status).toBe(405)
+
+    // Per-request error containment: a malformed %-escape answers 400 and the
+    // server keeps serving afterwards (no process-level failure path).
+    expect((await request(port, '/%zz')).status).toBe(400)
+    expect(await request(port, '/probe')).toMatchObject({ status: 200, body: 'EXACT' })
+
+    // Duplicate (kind, path) is a misconfiguration and throws; the disposer
+    // restores registrability (register/disposer symmetry).
+    expect(() => server.register({ kind: 'exact', path: '/probe', handler: () => {} }))
+      .toThrow(/duplicate exact route/)
+    const disposeOnce = server.register({ kind: 'exact', path: '/once', handler: (_req, res) => { res.writeHead(200); res.end('ONCE') } })
+    expect(await request(port, '/once')).toMatchObject({ status: 200, body: 'ONCE' })
+    disposeOnce()
+    expect((await request(port, '/once')).body).toContain('shell') // back to the SPA fallback
+    expect(() => server.register({ kind: 'exact', path: '/once', handler: () => {} })).not.toThrow()
+
+    // Teardown: fiber dispose closes the socket and severs held connections.
+    await loaded.fiber.dispose()
+    await expect(request(port, '/probe')).rejects.toThrow()
+  })
+
+  it('fails the fiber when the port is already taken (fail-loud at activation)', { timeout: 60_000 }, async () => {
+    const first = await loadComposition()
+    const takenPort = first.httpServer.port
+    const firstRoot = root
+    root = undefined // keep the first composition's files until the end
+
+    // loader.await() never rejects (allSettled); the bind failure surfaces as
+    // a FAILED fiber whose error escapes as a late rejection — the shape the
+    // boot's installFailLoud is contracted to catch. Capture it here the same
+    // way, and assert it really is the bind error.
+    const rejections: unknown[] = []
+    const onUnhandled = (err: unknown): void => { rejections.push(err) }
+    process.on('unhandledRejection', onUnhandled)
+    let second: Context | undefined
+    try {
+      second = await loadComposition(takenPort)
+      const entry = [...second.loader.entries()].find(e => e.options.name === '@deepseek-ai/dsh-host-webserver')
+      expect(entry?.fiber?.state).toBe(FiberState.FAILED)
+      // The rejection escapes a tick after loader.await() settles; bounded poll.
+      for (let i = 0; i < 100 && rejections.length === 0; i++) {
+        await new Promise(resolve => setTimeout(resolve, 10))
+      }
+      expect(rejections.map(String).join('\n')).toContain('EADDRINUSE')
+    } finally {
+      process.off('unhandledRejection', onUnhandled)
+      await second?.fiber.dispose()
+      context = first
+      if (root !== undefined) await rm(root, { recursive: true, force: true })
+      root = firstRoot
+    }
+  })
+})

+ 0 - 127
pnpm-lock.yaml

@@ -167,9 +167,6 @@ importers:
       '@deepseek-ai/dsh-host-apiproxy':
         specifier: workspace:^
         version: link:../../packages/host/apiproxy
-      '@deepseek-ai/dsh-host-runtime':
-        specifier: workspace:^
-        version: link:../../packages/host/runtime
       '@deepseek-ai/dsh-host-webserver':
         specifier: workspace:^
         version: link:../../packages/host/webserver
@@ -2245,130 +2242,6 @@ importers:
         specifier: ^4.0.0-rc.7
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
 
-  packages/host/runtime:
-    dependencies:
-      '@cordisjs/plugin-loader':
-        specifier: workspace:^
-        version: link:../../../vendor/loader
-      '@cordisjs/plugin-timer':
-        specifier: workspace:^
-        version: link:../../../vendor/timer
-      '@deepseek-ai/dsh-agent':
-        specifier: workspace:^
-        version: link:../../core/agent
-      '@deepseek-ai/dsh-agent-loop':
-        specifier: workspace:^
-        version: link:../../core/agent-loop
-      '@deepseek-ai/dsh-bash-local':
-        specifier: workspace:^
-        version: link:../../bash/bash-local
-      '@deepseek-ai/dsh-compact-basic':
-        specifier: workspace:^
-        version: link:../../compact/compact-basic
-      '@deepseek-ai/dsh-fs-local':
-        specifier: workspace:^
-        version: link:../../fs/fs-local
-      '@deepseek-ai/dsh-fs-policy':
-        specifier: workspace:^
-        version: link:../../fs/fs-policy
-      '@deepseek-ai/dsh-host-apiproxy':
-        specifier: workspace:^
-        version: link:../apiproxy
-      '@deepseek-ai/dsh-llm':
-        specifier: workspace:^
-        version: link:../../llm/llm
-      '@deepseek-ai/dsh-llm-deepseek':
-        specifier: workspace:^
-        version: link:../../llm/llm-deepseek
-      '@deepseek-ai/dsh-session':
-        specifier: workspace:^
-        version: link:../../core/session
-      '@deepseek-ai/dsh-session-persistence-jsonl':
-        specifier: workspace:^
-        version: link:../../session-persistence/session-persistence-jsonl
-      '@deepseek-ai/dsh-session-title':
-        specifier: workspace:^
-        version: link:../../session-title/session-title
-      '@deepseek-ai/dsh-session-title-first-message-llm':
-        specifier: workspace:^
-        version: link:../../session-title/session-title-first-message-llm
-      '@deepseek-ai/dsh-skill':
-        specifier: workspace:^
-        version: link:../../skill/skill
-      '@deepseek-ai/dsh-skill-local':
-        specifier: workspace:^
-        version: link:../../skill/skill-local
-      '@deepseek-ai/dsh-spill-local':
-        specifier: workspace:^
-        version: link:../../spill/spill-local
-      '@deepseek-ai/dsh-spill-policy':
-        specifier: workspace:^
-        version: link:../../spill/spill-policy
-      '@deepseek-ai/dsh-subagent':
-        specifier: workspace:^
-        version: link:../../subagent/subagent
-      '@deepseek-ai/dsh-subagent-fork':
-        specifier: workspace:^
-        version: link:../../subagent/subagent-fork
-      '@deepseek-ai/dsh-subagent-spawn':
-        specifier: workspace:^
-        version: link:../../subagent/subagent-spawn
-      '@deepseek-ai/dsh-system-prompt':
-        specifier: workspace:^
-        version: link:../../core/system-prompt
-      '@deepseek-ai/dsh-tasks':
-        specifier: workspace:^
-        version: link:../../tasks/tasks
-      '@deepseek-ai/dsh-timeout-policy':
-        specifier: workspace:^
-        version: link:../../timeout/timeout-policy
-      '@deepseek-ai/dsh-token-meter':
-        specifier: workspace:^
-        version: link:../../llm/token-meter
-      '@deepseek-ai/dsh-tool-bash':
-        specifier: workspace:^
-        version: link:../../bash/tool-bash
-      '@deepseek-ai/dsh-tool-fs':
-        specifier: workspace:^
-        version: link:../../fs/tool-fs
-      '@deepseek-ai/dsh-tool-fs-search':
-        specifier: workspace:^
-        version: link:../../fs/tool-fs-search
-      '@deepseek-ai/dsh-tool-skill':
-        specifier: workspace:^
-        version: link:../../skill/tool-skill
-      '@deepseek-ai/dsh-tool-subagent':
-        specifier: workspace:^
-        version: link:../../subagent/tool-subagent
-      '@deepseek-ai/dsh-tool-tasks':
-        specifier: workspace:^
-        version: link:../../tasks/tool-tasks
-      '@deepseek-ai/dsh-tool-todo':
-        specifier: workspace:^
-        version: link:../../todo/tool-todo
-      '@deepseek-ai/dsh-tool-workflow':
-        specifier: workspace:^
-        version: link:../../workflow/tool-workflow
-      '@deepseek-ai/dsh-tools':
-        specifier: workspace:^
-        version: link:../../core/tools
-      '@deepseek-ai/dsh-user-interaction':
-        specifier: workspace:^
-        version: link:../../ui/user-interaction
-      '@deepseek-ai/dsh-workflow-workerthread':
-        specifier: workspace:^
-        version: link:../../workflow/workflow-workerthread
-      '@deepseek-ai/dsh-workspace-context':
-        specifier: workspace:^
-        version: link:../../context/workspace-context
-    devDependencies:
-      '@deepseek-ai/dsh-invariants':
-        specifier: workspace:^
-        version: link:../../support/invariants
-      cordis:
-        specifier: ^4.0.0-rc.7
-        version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader)
-
   packages/host/webserver:
     dependencies:
       schemastery:

+ 0 - 1
scripts/verify-package-readme-model-experience.ts

@@ -66,7 +66,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/fs/fs-sandbox': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
   'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' },
   'packages/host/apiproxy': { kind: 'none', reason: 'The wire contract and fetch carriers move already-composed messages and register no model surface.' },
-  'packages/host/runtime': { kind: 'indirect', reason: 'The assembly mounts model-facing plugins and injects provider/model defaults into agents.' },
   'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers no model surface.' },
   'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' },
   'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' },

+ 0 - 1
tsconfig.base.json

@@ -99,7 +99,6 @@
       "@deepseek-ai/dsh-host-apiproxy": ["./packages/host/apiproxy/src"],
       "@deepseek-ai/dsh-host-apiproxy/client": ["./packages/host/apiproxy/src/fetch/client.ts"],
       "@deepseek-ai/dsh-host-apiproxy/*": ["./packages/host/apiproxy/src/*"],
-      "@deepseek-ai/dsh-host-runtime": ["./packages/host/runtime/src"],
       "@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"],
       "@deepseek-ai/dsh-client-ui-slots": ["./packages/client/ui-slots/src"],
       "@deepseek-ai/dsh-client-ui-primitives": ["./packages/client/ui-primitives/src"],

+ 0 - 1
tsconfig.host.json

@@ -142,7 +142,6 @@
     { "path": "./packages/hooks/hooks-codex" },
     { "path": "./packages/mcp/mcp-client" },
     { "path": "./packages/host/apiproxy" },
-    { "path": "./packages/host/runtime" },
     { "path": "./packages/host/webserver" },
     { "path": "./packages/sdk/helper" },
     { "path": "./packages/sdk/scripts" },

+ 1 - 0
vitest.config.ts

@@ -123,6 +123,7 @@ export default defineConfig({
         'packages/client/connection/src/http-bridge.ts',
         'packages/host/apiproxy/src/index.ts',
         'packages/host/apiproxy/src/invariant.ts',
+        'packages/host/apiproxy/src/api-proxy.ts',
         ...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`),
         ...windowsCoverageExclusions,
       ],