Merge remote-tracking branch 'origin/master' into worktree/web-session-titles

# Conflicts:
#	.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx
#	packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx
#	packages/client/ui-conversation/tests/selection-survival.spec.ts
#	packages/client/ui-conversation/tests/skeleton-branches.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
#	packages/client/ui-layout/tests/service.spec.ts
#	packages/client/ui-sidebar/tests/apply.spec.tsx
#	packages/client/ui-sidebar/tests/store.spec.ts
#	packages/client/ui-trajectory/tests/views.spec.tsx
#	packages/client/web/src/app.tsx
#	packages/client/web/tests/boot.spec.tsx
#	packages/host/runtime/README.md
#	packages/host/runtime/tests/host-runtime.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-23 18:39:48 +08:00
285 changed files with 11631 additions and 6072 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
architecture.md: 6ff2aa1ad4ca2ef051322f9d95631fe626d26e84
architecture.zh.md: b4b26efec16d85f1fb26589c5c9bffbb35e39564
architecture.md: 46b103ec788adbf7673e8b75643c71191318b42f
architecture.zh.md: 2684fe745fe8afd9ebf79f047dd9798ff432e506

View File

@@ -185,7 +185,7 @@ New behavior attaches to a documented extension point; a loop change updates thi
| Confine spawned processes | a `ctx.sandbox` backend; consumers wrap their argv before spawning |
| Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stop` is the serial terminal stop |
| Add a session-stable prefix outside history | compose `agent/session-prefix`; the request header logs it |
| Add UI or editor integration | drive `ctx.agents` and render from `session/event` |
| Add UI or editor integration | drive `ctx.agents` and render from `session/event`; terminal-only overlays use `ctx.tui` |
| Add durable session state | add a `SessionEventMap` member and render/replay from the log |
| Add asynchronous session-title generation | register the sole provider on `ctx.sessionTitle` |
| Manage a same-session objective | use `ctx.goals`; continue through `Agent` and `agent/*` |

View File

@@ -185,7 +185,7 @@ forever:
| 限制生成的进程 | 使用 `ctx.sandbox` 后端;消费方在生成进程前包装 argv |
| 拦截请求、工具或轮次 | 使用相应的 `agent/*``tools/*` 事件;`agent/turn-stop` 是串行终止判定点 |
| 添加历史记录之外的会话稳定前缀 | 组合 `agent/session-prefix`;请求头会记录该前缀 |
| 添加 UI 或编辑器集成 | 驱动 `ctx.agents` 并从 `session/event` 渲染 |
| 添加 UI 或编辑器集成 | 驱动 `ctx.agents` 并从 `session/event` 渲染;仅终端可用的浮层使用 `ctx.tui` |
| 添加持久会话状态 | 添加一个 `SessionEventMap` 成员,并从日志渲染和回放 |
| 添加异步会话标题生成 | 在 `ctx.sessionTitle` 上注册唯一提供方 |
| 管理同会话目标 | 使用 `ctx.goals`;通过 `Agent``agent/*` 续跑 |

View File

@@ -61,6 +61,7 @@ flowchart LR
svc_planMode["ctx.planMode<br/>Plan collaboration state"]
pkg_commands["commands"]
svc_commands["ctx.commands<br/>Human command registry"]
svc_tui["ctx.tui<br/>Mounted-terminal interaction service"]
pkg_skill["skill"]
svc_skills["ctx.skills<br/>Skill provider registry"]
pkg_skill_local["skill-local"]
@@ -172,6 +173,7 @@ flowchart LR
pkg_token_meter --> svc_tokenMeter
pkg_tool_bash --> svc_bashEnv
pkg_tools --> svc_tools
pkg_tui --> svc_tui
pkg_tui --> svc_userInteraction
pkg_user_interaction --> svc_userInteraction
pkg_web --> svc_web
@@ -277,6 +279,7 @@ flowchart LR
| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. |
| `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | [`acp`](../packages/ui/acp) | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. |
| `ctx.commands` | `core` | [`commands`](../packages/ui/commands) | - | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | Plugins register direct human commands; TUI and ACP consume the same effective per-agent catalog without sending invocations to the model. |
| `ctx.tui` | `bundle` | [`tui`](../packages/ui/tui) | - | - | - | One TUI front door provides a FIFO overlay host; injected plugins receive caller-fiber ownership without access to pi-tui or terminal lifecycle state. |
| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. |
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |

View File

@@ -1590,7 +1590,7 @@ export interface TuiConfig {
}
```
Source: [`packages/ui/tui/src/index.ts:161`](../packages/ui/tui/src/index.ts)
Source: [`packages/ui/tui/src/index.ts:216`](../packages/ui/tui/src/index.ts)
## `@deepseek-ai/dsh-tui-demo`

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
adding-a-package.md: 556a48493af4452c178634c0abb4e23e2419dd8e
adding-a-package.zh.md: 5f7e4692233448c746d25e4808c078c390cf39e6
adding-a-package.md: 1859310965538b35a353ee05c94b01d1093a3e43
adding-a-package.zh.md: 22f574a0469609e44f5c55957560ff0f04b9a053

View File

@@ -32,10 +32,11 @@ In-package relative imports use explicit `.ts` specifiers in source (for example
| File | Change |
|---|---|
| `tsconfig.base.json` | no edit for an existing group; for a new group, add a `./packages/<group>/*/src` candidate to the `@deepseek-ai/dsh-*` wildcard |
| `tsconfig.json` | add `{ "path": "./packages/<group>/<pkg>" }` to `references` |
| `tsconfig.build.json` | add `{ "path": "./packages/<group>/<pkg>" }` to `references` |
| `tsconfig.host.json` (host-side package) or `tsconfig.client.json` (client-side package) | add `{ "path": "./packages/<group>/<pkg>" }` to `references` — exactly one aggregate, never both ([layout](../development.md#typescript-project-layout)) |
| `knip.json` | only if the package has non-`*.spec.ts` entries (e.g. `*.e2e.ts` → add a per-workspace override like `packages/llm/llm-deepseek`) |
A `packages/client/*` package additionally extends `tsconfig.base.client.json` instead of `tsconfig.base.json`, and a client plugin package declares `dshClient` in package.json, exports `./client`, and calls the shared tsdown preset (`packages/client/tsdown.client.ts`) — see [packages/client/AGENTS.md](../../packages/client/AGENTS.md) for the client-side contract.
Covered automatically by globs or package-manifest discovery — no edits needed: root `package.json` workspaces, `scripts/publint-all.ts`, `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`, `scripts/check-workspace-constraints.ts`.
## 3. Decide the package topology

View File

@@ -32,10 +32,11 @@ package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-c
| 文件 | 变更 |
|---|---|
| `tsconfig.base.json` | 已有分组无需编辑;新分组需为 `@deepseek-ai/dsh-*` 通配符添加 `./packages/<group>/*/src` 候选路径 |
| `tsconfig.json` | 在 `references` 中添加 `{ "path": "./packages/<group>/<pkg>" }` |
| `tsconfig.build.json` | 在 `references` 中添加 `{ "path": "./packages/<group>/<pkg>" }` |
| `tsconfig.host.json`host 侧包)或 `tsconfig.client.json`client 侧包) | 在 `references` 中添加 `{ "path": "./packages/<group>/<pkg>" }`——恰好一个聚合,绝不两个都加([布局](../development.md#typescript-project-layout) |
| `knip.json` | 仅当包有非 `*.spec.ts` 入口时需要(如 `*.e2e.ts` → 添加 per-workspace override参照 `packages/llm/llm-deepseek` |
`packages/client/*` 包改为 extends `tsconfig.base.client.json`(而非 `tsconfig.base.json`client 插件包还需在 package.json 声明 `dshClient`、导出 `./client`、调用共享 tsdown preset`packages/client/tsdown.client.ts`——client 侧见 [packages/client/AGENTS.md](../../packages/client/AGENTS.md)。
以下内容由 glob 或包 manifest 发现机制自动覆盖,无需手动编辑:根 `package.json` workspaces、`scripts/publint-all.ts``tsdown.config.ts``vitest.config.ts``eslint.config.mjs``scripts/check-workspace-constraints.ts`
## 3. 确定包拓扑

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
adding-a-vendored-package.md: 1b82f2e582ca5cd040a7f3237505848dbb304fae
adding-a-vendored-package.zh.md: 7245682ef8b7d85ace2c626f8d47aa36f739506b
adding-a-vendored-package.md: 71ca9fccc9418348784dbb6668127242e4fb45d2
adding-a-vendored-package.zh.md: c340630aebeda0ec293a835cdfc8d15d71cd7801

View File

@@ -38,8 +38,7 @@ Local relative imports/exports in vendored TypeScript source use explicit `.ts`
| File | Change |
|---|---|
| `tsconfig.base.json` | add `"<npm-name>": ["./vendor/<dir>/src"]` to `paths` |
| `tsconfig.json` | add `{ "path": "./vendor/<dir>" }` to `references` |
| `tsconfig.build.json` | add `{ "path": "./vendor/<dir>" }` to `references` (before the `packages/*` entries) |
| `tsconfig.host.json` | add `{ "path": "./vendor/<dir>" }` to `references` (before the `packages/*` entries; vendored code enters the graph through the host aggregate only) |
| `vendor/README.md` | add a manifest table row (dir, npm name, version, upstream repo, commit SHA) and log any local modifications |
| `scripts/publint-all.ts` | only if the vendored package is itself published from here (vendored deps normally are not — skip) |
@@ -57,4 +56,4 @@ pnpm run typecheck
pnpm run build && pnpm run test && pnpm run constraints
```
The source `paths` map is shared by build and root typecheck configs. The important isolation boundary is the project-reference graph: vendored source must be referenced through its own `vendor/<dir>/tsconfig.json`, not pulled into a root strict program.
The source `paths` map lives once in `tsconfig.base.json` and serves every graph. The important isolation boundary is the project-reference graph: vendored source must be referenced through its own `vendor/<dir>/tsconfig.json`, not pulled into an aggregate's strict program ([layout](../development.md#typescript-project-layout)).

View File

@@ -38,8 +38,7 @@ vendored TypeScript 源码中的本地相对导入/导出在复制后使用显
| 文件 | 修改内容 |
|---|---|
| `tsconfig.base.json` | 在 `paths` 中添加 `"<npm-name>": ["./vendor/<dir>/src"]` |
| `tsconfig.json` | 在 `references` 中添加 `{ "path": "./vendor/<dir>" }` |
| `tsconfig.build.json` | 在 `references` 中添加 `{ "path": "./vendor/<dir>" }`(置于 `packages/*` 条目之前) |
| `tsconfig.host.json` | 在 `references` 中添加 `{ "path": "./vendor/<dir>" }`(置于 `packages/*` 条目之前vendored 代码只经 host 聚合进图) |
| `vendor/README.md` | 添加一行 manifest 表格行dir、npm name、version、upstream repo、commit SHA并记录所有本地修改 |
| `scripts/publint-all.ts` | 仅当该 vendored 包本身从此仓库发布时才需要vendored 依赖通常不发布——跳过) |
@@ -57,4 +56,4 @@ pnpm run typecheck
pnpm run build && pnpm run test && pnpm run constraints
```
源码 `paths` 映射由构建配置和根类型检查配置共享。重要的隔离边界是 project-reference 图vendored 源码必须通过其自身的 `vendor/<dir>/tsconfig.json` 被引用,而非被拉入根目录的严格程序中。
源码 `paths` 映射只在 `tsconfig.base.json` 存在一份,服务所有图。重要的隔离边界是 project-reference 图vendored 源码必须通过其自身的 `vendor/<dir>/tsconfig.json` 被引用,而非被拉入某个聚合的严格程序中[布局](../development.md#typescript-project-layout)

View File

@@ -1614,6 +1614,29 @@ Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-
Source: [`packages/core/tools/src/index.ts:524`](../../packages/core/tools/src/index.ts)
## `ctx.tui` — `TuiExtensionService` (abstract seam)
Optional terminal-local interaction service provided by one mounted TUI.
The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugins receive only effect-owned overlay sessions.
```ts cordis-catalog
/**
* Queue an interactive overlay owned by the calling plugin fiber.
*
* The TUI displays one overlay at a time in FIFO order. Disposing the caller
* removes a queued overlay or closes an active one before plugin teardown
* settles. This live presentation is neither logged nor replayed.
*
* @param request - component factory, layout constraints, and cancellation.
* @returns the effect-owned overlay session.
* @throws when the TUI has begun shutting down.
*/
abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
```
Source: [`packages/ui/tui/src/index.ts:131`](../../packages/ui/tui/src/index.ts)
## `ctx.userInteraction` — `UserInteractionService`
`ctx.userInteraction`: one active UI provider plus an `ask()` surface.

View File

@@ -1,6 +1,6 @@
# Cordis Primer
Cordis is the vendored plugin framework underneath the DeepSeek Harness SDK. This primer teaches the Cordis ideas a harness plugin author needs before reading the generated [events](cordis-catalog/events.md) and [services](cordis-catalog/services.md) catalogs. The vendored source and sync procedure live in [vendor/README.md](../vendor/README.md).
Cordis is the vendored plugin framework underneath the DeepSeek Harness SDK. This primer teaches the Cordis ideas a harness plugin author needs before reading the generated [events](cordis-catalog/events.md) and [services](cordis-catalog/services.md) catalogs; the [Cordis tutorial](cordis-tutorial/index.md) walks the same ideas hands-on. The vendored source and sync procedure live in [vendor/README.md](../vendor/README.md).
## Cordis In Five Ideas

View File

@@ -0,0 +1,93 @@
# 1. Your first plugin
In the loader configuration used here, a Cordis plugin module named-exports an `apply` function. When Cordis loads it, it calls `apply` with a **context** — the `ctx` object through which the plugin registers everything it contributes.
## Write the plugin
In your `tmp/cordis-tutorial` directory (see [setup](index.md#setup)), create `hello.ts`:
```ts
import type { Context } from 'cordis'
export const name = 'hello'
export function apply(ctx: Context) {
console.log('hello from my first plugin')
}
```
The `name` export is optional display metadata; it labels the plugin in diagnostics.
## Compose the app
This tutorial's launcher assembles the application from configuration. Create `cordis.yml`:
```yaml
- name: './hello.ts'
```
The file is a list of plugin entries. `name` is a module specifier — a relative path or an npm package name — and the loader mounts every entry. Entries start concurrently, so list position guarantees nothing about which plugin loads first; ordering comes from service dependencies (`inject`, [chapter 3](03-services.md)), not from position in the file.
## Run it
```sh
node --import tsx ../../vendor/cordis/bin.js
```
Expected output:
```
hello from my first plugin
```
The process exits on its own once nothing is left running. What happened:
1. The launcher created a root `Context` and mounted the **Loader** plugin.
2. The Loader read `cordis.yml`, resolved `./hello.ts`, and mounted it as a child plugin.
3. Cordis called your `apply(ctx)`.
There is no framework bootstrap code in your file: a plugin describes what it contributes, and `cordis.yml` composes the application. The [TUI agent](../../examples/tui-agent/cordis.yml), for example, is a longer plugin composition.
## The two other plugin shapes
A function is the most common shape, but Cordis accepts three:
```ts
import { Service, type Context } from 'cordis'
// 1. Function plugin (what you just wrote).
export function apply(ctx: Context) {}
// 2. Object plugin: an object with an `apply` method.
export const objectPlugin = {
name: 'object-plugin',
apply(ctx: Context) {},
}
// 3. Class plugin: a Service subclass (covered in chapter 3).
export class MyService extends Service {
constructor(ctx: Context) {
super(ctx, 'myTutorialService')
}
}
```
Use the function form until you need to expose a service; [chapter 3](03-services.md) covers when the class form earns its place.
## Try breaking it
Make `apply` throw:
```ts ignore-check
export function apply(ctx: Context) {
throw new Error('apply exploded')
}
```
Run again: the process dies with your error. A plugin that fails to load is a loud failure, not a skipped entry.
One caveat worth knowing early: a config entry whose module cannot be **resolved** — a typo'd path or package name — is reported through the Cordis logger service instead of crashing the process, and at boot that report can be lost before a console exporter is watching. If a freshly added entry seems to do nothing, check the spelling first.
Next: [Lifecycle and effects](02-lifecycle-and-effects.md) — what happens when a plugin unloads.
[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness)

View File

@@ -0,0 +1,96 @@
# 2. Lifecycle and effects
A Cordis plugin can be unloaded by a config edit, hot reload, explicit disposal, or loss of a required service. Registrations made through Cordis APIs are effects and are undone when their owning plugin unloads; resources managed outside those APIs must be wrapped in `ctx.effect()`.
## Effects
For a resource Cordis does not already manage — a timer, a connection, a watcher — wrap it in `ctx.effect()` and return a disposer:
Create `lifecycle.ts` in `tmp/cordis-tutorial`:
```ts
import type { Context } from 'cordis'
export const name = 'lifecycle-demo'
function heartbeat(ctx: Context) {
console.log('heartbeat plugin loading')
ctx.effect(() => {
const timer = setInterval(() => console.log('tick'), 200)
return () => {
clearInterval(timer)
console.log('heartbeat cleaned up')
}
})
}
export function apply(ctx: Context) {
// Mount a child plugin and keep its fiber to dispose it later.
const fiber = ctx.plugin(heartbeat)
// The demo timer is itself an effect: if THIS plugin is unloaded first,
// the pending callback is cancelled instead of firing on a dead app.
ctx.effect(() => {
const timer = setTimeout(async () => {
await fiber.dispose()
console.log('disposed')
process.exit(0)
}, 700)
return () => clearTimeout(timer)
})
}
```
Point `cordis.yml` at it:
```yaml
- name: './lifecycle.ts'
```
Run (`node --import tsx ../../vendor/cordis/bin.js`) and you get:
```
heartbeat plugin loading
tick
tick
tick
heartbeat cleaned up
disposed
```
Three things to notice:
- `ctx.plugin(heartbeat)` mounts a function **from code** as a plugin — the same operation the YAML loader performs for each config entry. A function plugin needs no `apply` method: Cordis calls the function directly and uses its name only for diagnostics. An `apply` method is required only for the object form, `ctx.plugin({ apply(ctx) { /* ... */ } })`. The call returns a **fiber**, the runtime handle for one loaded plugin instance.
- The effect body runs during load; the disposer it returns runs during unload. You never call the disposer yourself for a plugin-lifetime resource.
- `fiber.dispose()` resolves after all of the plugin's cleanup — including async disposers — has finished, and recursively unloads any child plugins it mounted.
## The fiber state machine
Every loaded plugin instance owns a fiber that moves through these states:
```
PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
↘ FAILED
```
- **PENDING** — declared, but a required service (chapter 3) is not available yet.
- **LOADING / ACTIVE** — `apply` is running / has completed.
- **FAILED** — `apply` or config validation threw.
- **UNLOADING / DISPOSED** — disposers are running / everything is torn down.
You will meet PENDING again in [chapter 6](06-composition-and-hmr.md), where it is the usual answer to "why does my plugin print nothing?".
## What is already an effect
You rarely write `ctx.effect()` yourself, because the built-in registration APIs are effects already:
- `ctx.on(event, listener)` — the listener is removed on unload ([chapter 4](04-events.md)).
- `ctx.plugin(child)` — the child is disposed with its parent.
- Service registrations are effects. Harness registries such as `ctx.tools.register(...)` also attach their returned disposers to the calling plugin, so they unwind automatically ([chapter 7](07-into-the-harness.md)).
For a resource Cordis does not manage, acquire it inside `ctx.effect()` and return a disposer that releases it. Cordis then invokes that release during unloading, including hot reload.
One ordering caveat: disposers start in reverse registration order, but multiple **async** disposers run concurrently. If teardown steps must run in sequence, keep them in one disposer and await them there.
Next: [Services](03-services.md) — how plugins share capabilities.
[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness)

View File

@@ -0,0 +1,96 @@
# 3. Services
A **service** is a named capability one plugin provides and other plugins consume through `ctx`. In the harness, `ctx.tools`, `ctx.llm`, and `ctx.agents` are services. A consumer names the capability, such as `'tools'`, rather than importing its provider, so configuration can select a provider without changing the consumer.
## Provide a service
Create `greeter.ts` in `tmp/cordis-tutorial`:
```ts
import { Service, type Context } from 'cordis'
declare module 'cordis' {
interface Context {
greeter: GreeterService
}
}
export class GreeterService extends Service {
constructor(ctx: Context) {
super(ctx, 'greeter')
}
greet(who: string) {
return `Hello, ${who}!`
}
}
export const name = 'greeter'
export function apply(ctx: Context) {
ctx.plugin(GreeterService)
}
```
Two pieces work together:
- **Runtime**: `super(ctx, 'greeter')` registers the instance under the name `greeter`. From then on, any plugin can reach it as `ctx.greeter`. The registration is an effect — unloading the provider removes the service.
- **Compile time**: the `declare module 'cordis'` block is TypeScript declaration merging. It adds `greeter` to the `Context` interface so `ctx.greeter` typechecks everywhere. It generates no code; without it the service still works at runtime, but consumers lose type safety.
A `Service` subclass is itself a plugin (the class form from chapter 1), so `ctx.plugin(GreeterService)` mounts it like any other.
## Consume a service with `inject`
Create `consumer.ts`:
```ts
import type { Context } from 'cordis'
export const name = 'consumer'
export const inject = ['greeter']
export function apply(ctx: Context) {
console.log(ctx.greeter.greet('world'))
}
```
`inject` lists the services this plugin requires. Cordis holds the plugin in PENDING until every listed service exists, so inside `apply`, `ctx.greeter` is guaranteed ready. Load order in `cordis.yml` does not matter — dependencies, not file order, decide when plugins start.
Compose and run:
```yaml
- name: './greeter.ts'
- name: './consumer.ts'
```
```
Hello, world!
```
Swap the two lines in `cordis.yml` and rerun: same output. Try removing `./greeter.ts` entirely: the consumer stays PENDING and prints nothing — no crash, no partial run. A PENDING fiber does not keep Node's event loop alive either, so a composition with nothing else running exits 0 silently. [Chapter 6](06-composition-and-hmr.md) shows how to diagnose that state.
## Dependencies are tracked after load
`inject` is not a one-shot boot check. If a required service disappears while the app runs — its provider was unloaded or hot-replaced — every dependent plugin is unloaded too, and loads again when the service returns. Combined with effects ([chapter 2](02-lifecycle-and-effects.md)), this prevents a running consumer from retaining a reference to an unavailable service: its own registrations are unwound when the dependency disappears.
This is also why service replacement works in config: unload the `dsh-bash-local` entry, mount a different `bash` provider, and every plugin injecting `'bash'` cleanly restarts against the new implementation.
## Optional dependencies
`inject` is for hard requirements. For a capability the plugin can live without, skip `inject` and probe at the use site:
```ts ignore-check
export function apply(ctx: Context) {
// undefined when no provider is loaded; the plugin still runs.
const greeter = ctx.get('greeter')
console.log(greeter?.greet('maybe') ?? 'no greeter available')
}
```
## Naming
Service names live in one flat namespace per application. Prefix or namespace your own services distinctively (the harness claims plain names like `tools` and `llm`); the generated [services catalog](../cordis-catalog/services.md) lists every name the harness registers.
Next: [Events](04-events.md) — communication without a shared service.
[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness)

View File

@@ -0,0 +1,142 @@
# 4. Events
Services support direct calls; **events** let a plugin announce something without knowing which plugins listen. The harness uses events for interactions such as tool results, model requests, and approval decisions.
## Declare, emit, listen
Create `stats.ts` in `tmp/cordis-tutorial` — a service that counts things and announces each change:
```ts
import { Service, type Context } from 'cordis'
declare module 'cordis' {
interface Context {
stats: StatsService
}
interface Events {
'stats/report'(name: string, count: number): void
}
}
export class StatsService extends Service {
private counts = new Map<string, number>()
constructor(ctx: Context) {
super(ctx, 'stats')
}
bump(name: string) {
const next = (this.counts.get(name) ?? 0) + 1
this.counts.set(name, next)
this.ctx.emit('stats/report', name, next)
}
}
export const name = 'stats'
export function apply(ctx: Context) {
ctx.plugin(StatsService)
}
```
The `interface Events` merge is the event-system twin of the `interface Context` merge from chapter 3: it declares the event name and its listener signature, so `ctx.emit` and `ctx.on` are fully typed. The `namespace/action` naming convention keeps the flat event namespace readable.
Create `reporter.ts`:
```ts ignore-check
import type { Context } from 'cordis'
import type {} from './stats.ts'
export const name = 'reporter'
export const inject = ['stats']
export function apply(ctx: Context) {
ctx.on('stats/report', (name, count) => {
console.log(`[stats] ${name} -> ${count}`)
})
ctx.stats.bump('tool_call')
ctx.stats.bump('tool_call')
ctx.stats.bump('prompt')
}
```
The `import type {} from './stats.ts'` line imports nothing at runtime; it exists so TypeScript sees the declaration merges. Compose and run:
```yaml
- name: './stats.ts'
- name: './reporter.ts'
```
```
[stats] tool_call -> 1
[stats] tool_call -> 2
[stats] prompt -> 1
```
Because `ctx.on()` is an effect, the listener disappears with the plugin — no manual `removeListener` bookkeeping, ever.
## Dispatch modes
`emit` is one of five dispatch modes. Which one an event uses is part of its contract — it decides whether listeners can return values, run concurrently, or short-circuit each other:
| Mode | Call | Semantics |
|---|---|---|
| emit | `ctx.emit(name, ...args)` | Synchronous broadcast; returned promises and values are not awaited or collected. |
| parallel | `await ctx.parallel(name, ...args)` | All listeners run concurrently; awaited together. |
| serial | `await ctx.serial(name, ...args)` | Listeners run in order, awaited; the first non-`null`/`false`/`undefined` return wins and stops the rest. |
| bail | `ctx.bail(name, ...args)` | Synchronous version of serial. |
| waterfall | `ctx.waterfall(name, ...args, next)` | Around-middleware; see below. |
Every harness event documents its mode in the generated [events catalog](../cordis-catalog/events.md).
## Waterfall: transform or short-circuit
Waterfall is the mode that powers interception. Each listener receives the arguments plus a `next()` continuation; it can transform what `next()` returns, or return without calling `next()` and short-circuit the rest of the chain — what the Cordis docs call the veto. Create `waterfall-demo.ts`:
```ts
import type { Context } from 'cordis'
declare module 'cordis' {
interface Events {
'demo/transform'(input: string, next: () => Promise<string>): Promise<string>
}
}
export const name = 'waterfall-demo'
export function apply(ctx: Context) {
// Listener 1: wrap the downstream result.
ctx.on('demo/transform', async (input, next) => {
const downstream = await next()
return downstream.toUpperCase()
})
// Listener 2: short-circuit when it owns the decision.
ctx.on('demo/transform', async (input, next) => {
if (input.includes('blocked')) return '** blocked **'
return next()
})
void (async () => {
console.log(await ctx.waterfall('demo/transform', 'hello', async () => 'hello'))
console.log(await ctx.waterfall('demo/transform', 'blocked words', async () => 'blocked words'))
})()
}
```
Point `cordis.yml` at just this file and run:
```
HELLO
** BLOCKED **
```
Walk through the second line: listener 1 runs first, calls `next()`, which invokes listener 2; listener 2 sees `blocked` and returns without calling `next()` — the innermost default (the function passed to `ctx.waterfall`) never runs — and listener 1 uppercases the replacement message on the way out.
The discipline that follows: **a waterfall listener that only observes or annotates must call `next()`**; returning without it is a deliberate short-circuit. Forgetting `next()` in a logging listener silently swallows the default behavior for everyone downstream. This is important enough that it is a standing rule of this repository ([waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)).
The harness uses waterfalls for decisions that cooperating plugins may wrap or answer: [`agent/request`](../cordis-catalog/events.md#agentrequest--waterfall) lets a plugin replace the model-call config, and [`approval/request`](../cordis-catalog/events.md#approvalrequest--waterfall) lets a policy answer instead of the user.
Next: [Configuration](05-config.md) — plugin options from `cordis.yml`.
[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness)

View File

@@ -0,0 +1,82 @@
# 5. Configuration
Each `cordis.yml` entry can carry a `config` block, and the plugin declares a schema that validates it before `apply` runs. Bad config fails the load with a precise error — the plugin never starts half-configured.
## A configurable plugin
Create `config-demo.ts` in `tmp/cordis-tutorial`:
```ts
import type { Context } from 'cordis'
import Schema from 'schemastery'
export const name = 'config-demo'
export interface Config {
greeting: string
targets: string[]
}
export const Config: Schema<Config> = Schema.object({
greeting: Schema.string().default('Hello'),
targets: Schema.array(String).default(['world']),
})
export function apply(ctx: Context, config: Config) {
for (const target of config.targets) {
console.log(`${config.greeting}, ${target}!`)
}
}
```
The exported `Config` is both a TypeScript interface and a runtime schema with the same name — consumers get the type, Cordis gets the validator. This repo uses [Schemastery](https://github.com/shigma/schemastery) for schemas; Cordis itself accepts any [Standard Schema](https://standardschema.dev/) validator, so a plain object exported as `Config` will not work.
Configure it:
```yaml
- name: './config-demo.ts'
config:
targets: ['alpha', 'beta']
```
Run:
```
Hello, alpha!
Hello, beta!
```
`greeting` was omitted, so the schema default filled it in — `apply` always receives complete, validated config.
## Fail loud
Now feed it something invalid:
```yaml
- name: './config-demo.ts'
config:
targets: 'not-an-array'
```
```
ValidationError: invalid config:
- $.targets expected array but got not-an-array (at targets)
```
The plugin's fiber goes to FAILED, and this tutorial's launcher exits with status 1 after printing the error. A plugin should also reject schema-valid config that names an unavailable resource or provider as soon as it can resolve that reference.
## Computed config values
The loader used in this repo supports a `!!js` tag for config values that must be computed at load time, such as reading an API key from the environment:
```yaml
- name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
```
`!!js` works **only inside `config`**. Entry metadata (`name`, `id`, `disabled`, `inject`, ...) is static; `disabled: !!js ...` produces a truthy expression object that always disables the entry. See [loader configuration](../cordis-primer.md#loader-configuration).
Next: [Composition and HMR](06-composition-and-hmr.md) — treating `cordis.yml` as the application.
[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness)

View File

@@ -0,0 +1,111 @@
# 6. Composition and HMR
Every capability built so far is a plugin, and `cordis.yml` selects the application's plugin tree. This chapter changes that composition, hot-reloads a plugin, and diagnoses a plugin that never loads.
## Entries are more than a name
A config entry accepts metadata beyond `name` and `config`:
```yaml
- id: greeter # stable identity for this entry
name: './greeter.ts'
- id: consumer
name: './consumer.ts'
disabled: true # keep the entry, skip mounting it
```
`id` gives the entry a stable identity so the loader can tell an edit to an existing entry apart from a removal plus an addition. `disabled: true` unmounts a plugin without deleting its entry — flip it back and the plugin (and everything PENDING on its services) loads again.
Groups nest a sub-list of entries that load and unload as one unit, and `isolate` gives a group its own instance of a service name — two groups can each see a differently-configured `bash` without affecting each other. Those are worth knowing about before you need them; the [Cordis primer](../cordis-primer.md) and the [service isolation example](../user/develop/framework/service.md#service-isolation) cover the details.
## Hot module replacement
Because unloading releases effects ([chapter 2](02-lifecycle-and-effects.md)) and loading follows dependencies ([chapter 3](03-services.md)), HMR can replace a running plugin by unloading and loading it. The `@cordisjs/plugin-hmr` plugin watches your files and does exactly that on save.
In `tmp/cordis-tutorial`, write `cordis.yml`:
```yaml
- id: logger
name: '@cordisjs/plugin-logger-console'
- id: timer
name: '@cordisjs/plugin-timer'
- id: hmr
name: '@cordisjs/plugin-hmr'
config:
root: ['.']
- id: hello
name: './hello.ts'
```
Two support plugins joined the list: HMR logs through the Cordis logger service, so without a console exporter you would not see its messages, and it `inject`s the `timer` service for debouncing — without `@cordisjs/plugin-timer` it sits in PENDING forever, silently. That silence is the subject of the next section.
HMR also needs Node's loader internals:
```sh
node --expose-internals --import tsx ../../vendor/cordis/bin.js
```
Now edit `hello.ts` — change the log message — and save:
```
hello from my first plugin
2026-07-22 15:44:36 [I] hmr watching [ '.' ]
2026-07-22 15:44:39 [I] hmr reload plugin at hello.ts
hello from my EDITED plugin
```
The old instance unloaded (all its effects unwound), the new code loaded, `apply` ran again. Stop the process with Ctrl-C. Editing `cordis.yml` itself is also picked up: the loader diffs entries by `id` and mounts, unmounts, or reconfigures only what changed. This is why the entries above carry explicit `id`s — an entry without one gets a generated id on every read, so after any config-file edit it counts as removed-plus-added and remounts even if its own lines did not change.
## Diagnosing a plugin that never loads
The flip side of dependency-driven loading: a plugin whose `inject` names a service nobody provides waits forever, printing nothing. No error — PENDING is a legitimate state, since the provider may be mounted later.
You can see the states directly. Every context can enumerate the plugin registry; create `diagnose.ts`:
```ts
import { FiberState, type Context } from 'cordis'
export const name = 'diagnose'
export function apply(ctx: Context) {
setTimeout(() => {
for (const runtime of ctx.registry.values()) {
for (const fiber of runtime.fibers) {
if (fiber.state === FiberState.PENDING) {
console.log(`${fiber.name} is PENDING — a required service is missing`)
}
}
}
}, 500)
}
```
And a plugin with an unsatisfiable dependency, `needs-timer.ts`:
```ts
import type { Context } from 'cordis'
export const name = 'needs-timer'
export const inject = ['timer']
export function apply(ctx: Context) {
console.log('needs-timer loaded')
}
```
```yaml
- name: './needs-timer.ts'
- name: './diagnose.ts'
```
Run it (plain `node --import tsx ../../vendor/cordis/bin.js`; stop with Ctrl-C):
```
needs-timer is PENDING — a required service is missing
```
`inject: ['timer']` has no provider. Add `- name: '@cordisjs/plugin-timer'` to the list and the plugin loads. When a plugin does nothing and reports nothing, inspect its fiber state. Iterating without the PENDING filter also shows the loader's own plugins (Loader, Include) as ACTIVE fibers because plugins mount the config file itself.
Next: [Into the harness](07-into-the-harness.md) — the same patterns against real harness services.
[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness)

View File

@@ -0,0 +1,101 @@
# 7. Into the harness
This chapter registers a model-callable tool with the harness's `tools` service, executes it through the harness tool pipeline, and observes the result event. It remains keyless and does not call a model.
## A tool plugin
Create `greet-tool.ts` in `tmp/cordis-tutorial`:
```ts
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { CallId } from '@deepseek-ai/dsh-llm'
export const name = 'greet-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'greet',
description: 'Greet the named person.',
parameters: {
name: { type: 'string', required: true, description: 'Who to greet' },
},
async execute(args) {
return [{ type: 'text', text: `Hello, ${args.name}!` }]
},
}))
// Drive one call through the real execution pipeline, standing in for
// the model. CallId brands the correlation id a provider would issue.
void (async () => {
const result = await ctx.tools.execute({
callId: CallId('demo-1'),
name: 'greet',
arguments: { name: 'Cordis' },
signal: new AbortController().signal,
})
console.log('tool replied:', JSON.stringify(result.content))
})()
}
```
Every pattern here is from the earlier chapters: `inject: ['tools']` ([chapter 3](03-services.md)) holds the plugin until the tool registry exists; `ctx.tools.register(...)` attaches the registration disposer to the plugin ([chapter 2](02-lifecycle-and-effects.md)), so unloading unregisters the tool. `defineTool` converts the `parameters` spec to the JSON Schema shown to the model, infers the type of `args`, and validates model-supplied arguments before `execute` runs.
## An observer plugin
Create `tool-logger.ts` — a separate plugin that watches every tool call in the app through the harness's `tools/result` event:
```ts
import type { Context } from 'cordis'
import type {} from '@deepseek-ai/dsh-tools'
export const name = 'tool-logger'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.on('tools/result', (exec, result) => {
const text = result.content
.map(block => (block.type === 'text' ? block.text : ''))
.join('')
console.log(`[tool-logger] ${exec.name} -> ${text}`)
})
}
```
The `import type {} from '@deepseek-ai/dsh-tools'` line pulls in the package's declaration merges so `'tools/result'` and its payload are typed — the same move as chapter 4's `stats.ts` import, at package scale.
## Compose and run
```yaml
- name: '@deepseek-ai/dsh-system-prompt'
- name: '@deepseek-ai/dsh-tools'
- name: './tool-logger.ts'
- name: './greet-tool.ts'
```
`@deepseek-ai/dsh-tools` injects the `systemPrompt` service because tools contribute schemas to the system prompt, so the composition lists its provider too. Without it, the tools plugin remains PENDING as described in [chapter 6](06-composition-and-hmr.md).
```sh
node --import tsx ../../vendor/cordis/bin.js
```
```
[tool-logger] greet -> Hello, Cordis!
tool replied: [{"type":"text","text":"Hello, Cordis!"}]
```
The logger fired first: `tools/result` is emitted as part of result materialization, before `execute`'s promise resolves to the caller. Neither of your plugins knows the other exists — the registry service and the event connect them.
## From here to a full agent
A real agent is this composition plus more plugins: an LLM adapter, the agent loop, persistence, a front end. Compare [examples/headless-agent/cordis.yml](../../examples/headless-agent/cordis.yml) — you can read every entry in it now. Add your `greet-tool.ts` to a copy of that file.
Where to go next:
- [Build a tool](../user/develop/basic/tool.md) — more of `defineTool`, including presentation and richer schemas.
- [Three-layer capability design](../user/develop/practice/index.md) — how the harness structures replaceable capabilities.
- The generated [services](../cordis-catalog/services.md) and [events](../cordis-catalog/events.md) catalogs — everything you can inject and listen to.
- [Architecture](../architecture.md) — the system map these plugins live in.
[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness)

View File

@@ -0,0 +1,54 @@
# Cordis tutorial
Cordis is the plugin framework underneath the DeepSeek Harness SDK: a small runtime where every capability — tools, LLM adapters, file access, the agent loop itself — is a plugin mounted into a shared context. This tutorial teaches Cordis hands-on: each chapter is a runnable example you build in a scratch directory inside this repository, ending with a plugin wired into real harness services.
The audience is agent developers. You do not need deep TypeScript experience; the [TypeScript notes](#typescript-notes) below explain the syntax that may be unfamiliar, and every chapter shows the exact commands and expected output.
If you want the condensed concept reference instead of a walkthrough, read the [Cordis primer](../cordis-primer.md). The exhaustive API reference lives in the generated [events](../cordis-catalog/events.md) and [services](../cordis-catalog/services.md) catalogs and the [Cordis core API](../cordis-catalog/core/context.md) pages.
## Setup
You need a clone of this repository with dependencies installed — the [quick start](../user/guide/quickstart.md) covers prerequisites. No API key is needed for this tutorial; every example runs keylessly.
```sh
git clone https://github.com/deepseek-harness/deepseek-harness.git
cd deepseek-harness
pnpm install
```
Create the scratch directory the chapters work in. `tmp/` is gitignored, so nothing you write there touches version control:
```sh
mkdir -p tmp/cordis-tutorial
cd tmp/cordis-tutorial
```
Every chapter runs the same command from this directory:
```sh
node --import tsx ../../vendor/cordis/bin.js
```
That one-file launcher (see [vendor/cordis/bin.js](../../vendor/cordis/bin.js)) creates a root `Context`, mounts the Loader plugin, and tells it to load `./cordis.yml` from the current directory. Everything else — which plugins exist, how they are configured — comes from that YAML file, which you will write in a moment. The `--import tsx` flag lets Node run the TypeScript files the config points at without a build step.
## Chapters
1. [Your first plugin](01-first-plugin.md) — a plugin is a function; the loader mounts it.
2. [Lifecycle and effects](02-lifecycle-and-effects.md) — Cordis-managed registrations are undone when their plugin unloads.
3. [Services](03-services.md) — expose a capability on `ctx` and depend on it with `inject`.
4. [Events](04-events.md) — typed events, broadcast dispatch, and the waterfall short-circuit.
5. [Configuration](05-config.md) — validated config from `cordis.yml`, failing loud on bad input.
6. [Composition and HMR](06-composition-and-hmr.md) — the config file as a plugin tree, hot reload, and diagnosing a plugin that never loads.
7. [Into the harness](07-into-the-harness.md) — register a model-callable tool against real harness services.
## TypeScript notes
The examples use three TypeScript features beyond ordinary modern JavaScript:
- **Type annotations** describe values without changing runtime behavior: `ctx: Context` says that `ctx` has the Cordis context API, `who: string` accepts text, and `string[]` means an array of strings.
- **`import type { Context } from 'cordis'`** imports only type information. It vanishes at runtime, so a plugin file that needs `Context` solely for annotations adds no runtime dependency.
- **Declaration merging** (`declare module 'cordis' { ... }`) adds your entries to interfaces that Cordis already declares — for example the type of a new `ctx.greeter` property or event name. It generates no runtime wiring; the plugin separately provides the service or emits the event. Chapter 3 shows the pattern in full.
Chapter 5 also uses an `interface` to describe a configuration object's fields and a generic type such as `Schema<Config>` to say which object shape a schema validates. You can copy those declarations as shown; the surrounding text explains what each one connects.
[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness)

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
development.md: 3559b09d86395707f222aad0a281c9db1246c24f
development.zh.md: 8664a3291c04a5338fdadbce8f24b160cc9ec0a8
development.md: 4294038e40aa774a006874e6641ca63eea44beeb
development.zh.md: 1f07c95dd60d0554b945c29e6e3ba8bc6ca9841a

View File

@@ -33,7 +33,26 @@ Run typecheck once after a fresh clone:
pnpm run typecheck
```
That first typecheck runs the package/vendor build graph and the root no-emit `tsconfig.json` graph for examples, tests, and scripts. The root graph uses the same source `paths` map but relies on project references so vendored code is checked under its own tsconfig settings.
That first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.
## TypeScript project layout
The repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.
| File | Role | Forms a program? |
|---|---|---|
| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |
| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |
| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |
| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |
| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |
Host and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:
- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.
- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.
Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).
If a relevant local check consumes built package output, build once first:
@@ -59,7 +78,7 @@ DEEPSEEK_BASE_URL=https://... # optional
lefthook is configured in `lefthook.yml` as a fast local checkpoint:
- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.
- `pre-push` runs only the incremental repository typecheck.
- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).
The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.
@@ -80,7 +99,7 @@ pnpm run test # unit tests
pnpm run test:coverage # unit tests with per-file coverage gates
pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY
pnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks
pnpm run typecheck # build package/vendor outputs, then typecheck examples, tests, and scripts
pnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates
pnpm run lint # eslint .
pnpm run lint:fix # eslint . --fix
pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs

View File

@@ -33,7 +33,26 @@ pnpm exec lefthook install --force
pnpm run typecheck
```
首次类型检查会执行 package/vendor 的构建图,以及根目录下用于示例、测试和脚本的 no-emit `tsconfig.json` 项目图。根图使用同一份源码 `paths` 映射,但依赖 project references因此 vendor 代码在它自己的 tsconfig 设置下被检查
首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本
## TypeScript 项目布局
仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。
| 文件 | 角色 | 是否构成 program |
|---|---|---|
| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/``scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |
| `tsconfig.host.json` | host 聚合host 侧各包(经 references、示例、测试、脚本、website。排除 `packages/client`。 | 是 |
| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |
| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |
| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |
host 与 client 保持两个聚合 program是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:
- `tsconfig.base.json` 永不添加 `include``files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。
- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json``tsconfig.client.json`——永不种子根 solution因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧client 侧只在出现真实需求时再获得基于 program 的工具。
静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md)tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。
如果相关的本地检查需要使用构建后的包产物,请先构建一次:
@@ -59,7 +78,7 @@ DEEPSEEK_BASE_URL=https://... # optional
lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:
- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest元数据清单守卫
- `pre-push` 只运行仓库增量类型检查。
- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)
vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`
@@ -80,7 +99,7 @@ pnpm run test # unit tests
pnpm run test:coverage # unit tests with per-file coverage gates
pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY
pnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks
pnpm run typecheck # build package/vendor outputs, then typecheck examples, tests, and scripts
pnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates
pnpm run lint # eslint .
pnpm run lint:fix # eslint . --fix
pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs

View File

@@ -29,6 +29,10 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword
- A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green under a broken export shape — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert.
- "Real entry path" means the published artifact: a package `bin` runs built `lib/bin.js` under plain `node`, exposing failures tsx masks (settle races, module resolution, swallowed load failures). The same applies to non-index runtime entries (the worker-thread sibling `lib/worker.cjs`) and singleton modules shared across bundles (`packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero.
## Test resolution: source plane only
- Every vitest config points vite-tsconfig-paths at `tsconfig.base.json`; bare workspace imports resolve to `src` ([layout](development.md#typescript-project-layout)), never through package `exports` to built `lib/` — stale artifacts there load a second copy of module singletons. Built artifacts are consumed only explicitly: `lib`-mode subprocesses and the built smokes below.
## Test subprocess launch modes
- CI and build-having test lanes run every example or Cordis-config subprocess from built `lib/` through the shared dual-mode launcher. Do not hand-write `--import tsx` for these subprocesses.

View File

@@ -1,6 +1,6 @@
# Web GUI 样式规范
> **【token 体系已换代——§1 表格仅历史参考】** 本文的 `--bg-*`/`--text-*`/`--accent` token 族与其宿主包 `packages/client/web-ui` 已随插件化重构退役。现行 token 唯一来源=`packages/client/ui-theme/src/styles/` 的 `--dsw-*` 体系static 色阶+alias 语义层,暗色=`body[data-ds-dark-theme]` 覆写);组件对账基准=`missions/tasks/20260721-1520-web-plugin-rfc/style-spec.md`。**仍然有效**工程约束CSS Modules + clsx、无组件库、无 tailwind、组件禁 hardcode 色值)、字号成对写行高、间距 4 倍数、代码字体栈末位不放 monospace——这些已收编进 architecture.md §15
> **【token 体系已换代——§1 表格仅历史参考】** 本文的 `--bg-*`/`--text-*`/`--accent` token 族与其宿主包 `packages/client/web-ui` 已随插件化重构退役。现行 token 唯一来源=`packages/client/ui-theme/src/styles/` 的 `--dsw-*` 体系static 色阶+alias 语义层,暗色=`body[data-ds-dark-theme]` 覆写)sheet 即权威、组件对账以它为准。**仍然有效**工程约束CSS Modules + clsx、无组件库、无 tailwind、组件禁 hardcode 色值)、字号成对写行高、间距 4 倍数、代码字体栈末位不放 monospace。
> 状态:原「活文档」(随 `packages/client/web-ui` 演进)。视觉基线源自对 deepseekchat 前端仓的实测调研。框架决策与工程约束由 [web-styling-system RFC](../.agents/notes/implemented/process/2026-07-19-web-styling-system.md) 拍板,本文不重复论证。