Merge remote-tracking branch 'upstream/master' into fix/workspace-context-rendered-change-proof

This commit is contained in:
ZiyaZhang
2026-07-30 03:50:39 -07:00
220 changed files with 2783 additions and 2761 deletions

View File

@@ -86,7 +86,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore
Bringing up a new `packages/client/<name>` plugin package (ui-workspace is the latest walked example; ui-sidebar/ui-question are good skeletons to copy):
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/config/web.cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else.
4. **Registering into another package's slot**: if the declaring host provides no waitable service, your apply's order relative to the host's is unconstrained — a bare `slots.register` into its slot races boot (intermittent `slot "..." is not declared` page failures). Register with declaration-aware deferral: check `ctx.slots.spec(name)`, otherwise `ctx.slots.subscribe(name)` and register on the declaration event (SlotCore supports subscribing ahead of declaration); make the registration idempotent, and unsubscribe + dispose in the effect disposer. Only take a service edge in `inject` when the host actually provides one (ui-question → `'conversation'` is that case).
5. Rebuild the bundle (`pnpm --filter <pkg> bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources.

View File

@@ -134,6 +134,15 @@ interface PreparedAgent {
declare module 'cordis' {
interface Context {
agentLoop: AgentLoop
/**
* Launcher-owned exact session identities for configured agents, keyed by
* the agent's config `id` and set with `ctx.provide()` before any Loader
* entry mounts (see {@link CONFIGURED_AGENT_IDENTITIES_KEY}). A launcher
* owns identity because only it knows whether the session already exists,
* while the `cordis.yml` row keeps the model route as ordinary patchable
* config. An entry with no matching key keeps its configured identity.
*/
configuredAgentIdentities?: ConfiguredAgentIdentities
}
interface Events {
/**
@@ -151,6 +160,53 @@ declare module 'cordis' {
export { DEFAULT_MAX_PARALLEL_TOOL_CALLS }
/**
* One launcher-selected session identity for a configured agent. `resume`
* distinguishes rehydrating existing persisted history from creating the
* session fresh under that exact id, which the two config keys express as
* `resumeSessionId` and `sessionId`.
*/
export interface LauncherAgentIdentity {
/** Exact session id to create fresh or resume. */
id: SessionId
/** Resume existing persisted history instead of creating the session fresh. */
resume: boolean
}
/** Launcher-selected identities keyed by the configured agent's `id`. */
export interface ConfiguredAgentIdentities extends Readonly<Record<string, LauncherAgentIdentity>> {}
/**
* Context key a launcher sets before any Loader entry mounts
* (`ctx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, identities)`) to fix
* configured agents' session identities without a config key, so an overlay
* repointing the row's model route cannot drop them.
*/
export const CONFIGURED_AGENT_IDENTITIES_KEY = 'configuredAgentIdentities'
/**
* Apply launcher-owned identities over the configured agents, replacing both
* identity keys for every entry the launcher named so a config-supplied
* identity can never survive alongside a launcher-supplied one.
* @param agents - the configured agent entries.
* @param identities - launcher identities keyed by configured agent `id`, or `undefined`.
* @returns the entries with launcher-owned identities applied.
*/
function applyLauncherIdentities(
agents: Config['agents'],
identities: ConfiguredAgentIdentities | undefined,
): Config['agents'] {
if (identities === undefined) return agents
return agents.map((agent) => {
const identity = identities[agent.id]
if (identity === undefined) return agent
const { sessionId: _sessionId, resumeSessionId: _resumeSessionId, ...rest } = agent
return identity.resume
? { ...rest, resumeSessionId: identity.id }
: { ...rest, sessionId: identity.id }
})
}
/** Agent-loop plugin configuration. */
export interface Config {
/**
@@ -220,6 +276,7 @@ export class AgentLoop extends Service implements AgentFactory {
super(ctx, 'agentLoop')
this.config = {
...config,
agents: applyLauncherIdentities(config.agents, ctx.get(CONFIGURED_AGENT_IDENTITIES_KEY)),
maxParallelToolCalls: resolveMaxParallelToolCalls(config.maxParallelToolCalls),
}
validateConfiguredAgents(this.config.agents)

View File

@@ -11,7 +11,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import AgentLoop, { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
const dirs: string[] = []
@@ -36,6 +36,26 @@ async function makeCoreContext(): Promise<Context> {
}
describe('config-driven session id', () => {
it('applies launcher identities by configured id without changing unmatched entries', async () => {
const ctx = await makeCoreContext()
ctx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, {
fresh: { id: SessionId('launcher-fresh'), resume: false },
resumed: { id: SessionId('launcher-resumed'), resume: true },
})
await ctx.plugin(AgentLoop, {
agents: [
{ id: 'fresh', sessionId: SessionId('config-fresh'), model: 'mock' },
{ id: 'resumed', sessionId: SessionId('config-resumed'), model: 'mock' },
{ id: 'unchanged', sessionId: SessionId('config-unchanged'), model: 'mock' },
],
})
expect(ctx.agents.get(SessionId('launcher-fresh'))?.session.id).toBe('launcher-fresh')
expect(ctx.agents.get(SessionId('launcher-resumed'))).toBeUndefined()
expect(ctx.agents.get(SessionId('config-resumed'))).toBeUndefined()
expect(ctx.agents.get(SessionId('config-unchanged'))?.session.id).toBe('config-unchanged')
await ctx.fiber.dispose()
})
it('rejects an empty exact id before publishing an agent', async () => {
const ctx = await makeCoreContext()
await expect(ctx.plugin(AgentLoop, {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/examples/README.md
README.md: c229cef22087ac290bf862d6b3e31fdb533858c4
README.zh.md: 208b9a138506785ea1dd2d83ddfbba29e4b7968e
README.md: d3ad432e71036db0d21f059e52f5d32e58010c42
README.zh.md: 0218e60df2511974b8eb222e23331c5a70c9df60

View File

@@ -7,12 +7,11 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling
| Package | npm name | Role |
|---|---|---|
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin, with fallback session titles and an opt-in persisted-goal stack |
| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app bundle: the spine + persisted goals + `/goal` command + JSONL persistence + `dsh-tui` + a pre-created `main` agent; no bin, booted by the [`dsh`](../../apps/cli/README.md) CLI |
| `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output |
| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP automation server app: the spine + persisted goals + JSONL persistence + the [`acp`](../acp/acp/README.md) bridge (no stdout logger), with a boot `bin` |
| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client |
`agent-spine-demo` is the shared bundle; `tui-demo`, `cli-demo`, and `acp-demo` compose it with full-screen terminal, headless one-shot, and ACP automation front doors. `cli-demo` and `acp-demo` own their boot bins; `tui-demo` ships only the bundle plugin, and the product [`dsh`](../../apps/cli/README.md) CLI is its terminal front door. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
`agent-spine-demo` is the shared bundle; `cli-demo` and `acp-demo` compose it with headless one-shot and ACP automation front doors, and own their boot bins. The product [`dsh`](../../apps/cli/README.md) CLI uses no bundle: its TUI and web surfaces are a shared `base.cordis.yml` plus one overlay each. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
These are **not** product API. The spine pieces they bundle live in [`core/`](../core/README.md), human/SDK channels and boot glue in [`ui/`](../ui/README.md), the automation transport in [`acp/`](../acp/README.md), and swappable backends in their capability groups; a demo bundle just picks one concrete composition of them. Swap or fork one freely.

View File

@@ -2,17 +2,16 @@
[English](README.md) | 中文
预先组合的插件 bundle组合包供轻量叶节点 `cordis.yml` 加载,无需手工组装主干和前端入口。这些是 **演示/参考**packagenpm 名称的 `-demo` 后缀把每个包标为非产品表层,直接查看包名即可辨认。仓库根目录 [`examples/`](../../examples/AGENTS.md) 下的可运行叶节点与 [Python SDK 运行时](../../python/sdk-runtime/README.md) 是消费方;每个消费方都只包含可替换后端和一个组合包入口。
预先组合的插件 bundle组合包供轻量叶节点 `cordis.yml` 加载,无需手工组装主干和前端入口。这些是 **演示/参考**npm 名称的 `-demo` 后缀把每个包标为非产品表层,直接查看包名即可辨认。仓库根目录 [`examples/`](../../examples/AGENTS.md) 下的可运行叶节点与 [Python SDK runtime](../../python/sdk-runtime/README.md) 是消费方;每个叶节点都只包含可替换后端和一个组合包入口。
| 包 | npm 名称 | 角色 |
|---|---|---|
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | 不含执行器和 UI 的 agent(智能体)主干,打包为一个组合包插件,带后备会话标题和可选择启用的持久目标栈 |
| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | 全屏终端应用组合包:主干 + 持久化目标 + `/goal` 命令 + JSONL 持久化 + `dsh-tui` + 预创建的 `main` agent没有 bin由 [`dsh`](../../apps/cli/README.md) CLI命令行界面启动 |
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | 不含执行器和 UI 的 agent 主干,打包为一个组合包插件,带后备会话标题和用的持久目标栈 |
| `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | 无头单次应用:主干 + JSONL 持久化 + 预创建的 `main` agent提供文本和 DSH 原生 JSON 输出 |
| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACPAgent Client Protocol自动化服务器应用:主干 + 持久目标 + JSONL 持久化 + [`acp`](../acp/acp/README.md) 桥接层(无 stdout logger带启动 `bin` |
| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | 只有 bin 的运行时,用于启动外部 `cordis.yml`,供 stdio JSON-RPC SDK 客户端使用 |
| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP 自动化服务器应用:主干 + 持久目标 + JSONL 持久化 + [`acp`](../acp/acp/README.md) 桥接层(无 stdout logger带启动 `bin` |
| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | 只有 bin 的 runtime,用于启动外部 `cordis.yml`,供 stdio JSON-RPC SDK 客户端使用 |
`agent-spine-demo` 是共享组合包;`tui-demo``cli-demo``acp-demo` 分别将它与全屏终端、无头单次和 ACP 自动化前端入口组合`cli-demo``acp-demo` 拥有各自的启动 bin`tui-demo` 只交付组合包插件,产品 [`dsh`](../../apps/cli/README.md) CLI 是它的终端前端入口`jsonrpc-demo` 自身不挂载任何组合,而是启动部署的 `cordis.yml` 所指名的任意插件树Python SDK 运行时会启动它。
`agent-spine-demo` 是共享组合包;`cli-demo``acp-demo` 分别将它与无头单次和 ACP 自动化前端入口组合,并拥有各自的启动 bin。产品 [`dsh`](../../apps/cli/README.md) CLI 不使用组合包:其 TUI 与 web surface 都是一份共享的 `base.cordis.yml` 加各自一份 overlay`jsonrpc-demo` 自身不挂载任何组合,而是启动部署的 `cordis.yml` 所指名的任意插件树Python SDK runtime 会启动它。
这些 **不是** 产品 API。它们打包的主干组件位于 [`core/`](../core/README.md)人类SDK 通道和启动粘合代码位于 [`ui/`](../ui/README.md),自动化传输位于 [`acp/`](../acp/README.md),可替换后端位于各自能力组;演示组合包只选定其中一种具体组合。可以自由替换或 fork。
@@ -20,4 +19,4 @@
## jsonrpc binexe 名称是历史遗留
`jsonrpc-demo` 已像同级包一样重命名,但其 bin 仍为 `dsh-jsonrpc-agent`,单文件可执行程序仍为 `dsh-jsonrpc-agent-pkg`(在 [Python 分发](../../python/sdk-runtime/README.md)各处被引用)。这些名称属于 SDK 的运行时启动表层;只有 SDK 统一该启动流程时才会协调它们,而不会在此次移动中处理。
`jsonrpc-demo` 已像同级包一样重命名,但其 bin 仍为 `dsh-jsonrpc-agent`,单文件可执行程序仍为 `dsh-jsonrpc-agent-pkg`(在 [Python 分发](../../python/sdk-runtime/README.md)各处被引用)。这些名称属于 SDK 的 runtime 启动表层;只有 SDK 统一该启动流程时才会协调它们,而不会在此次移动中处理。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/examples/agent-spine-demo/README.md
README.md: 6bbd99217bcdce0e8a0e8fd22a8d39d0c64224b9
README.zh.md: 4a7e7a1b7eced9317f94eeddf2442ded9a3e0e13
README.md: 34d68b0791746c28528124853a4d8ea82b68138d
README.zh.md: 1b0a644595e35d8703d0600812268b0950b79182

View File

@@ -47,7 +47,7 @@ The spine is everything COMMON to every front door. The swappable and front-door
- **model-backed session-title providers** — the bundle mounts the fallback service with overridable example limits (5 words, 40 fallback bytes, 80 accepted-title bytes); a leaf may opt into exactly one first-message or all-messages LLM provider.
- **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl).
- **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings.
- **front-door + per-app infra** — the terminal TUI or ACP automation transport and `hmr`. App packages ([`dsh-tui-demo`](../tui-demo/README.md), [`dsh-acp-demo`](../acp-demo/README.md)) own those choices. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside.
- **front-door + per-app infra** — the terminal TUI or ACP automation transport and `hmr`. App packages ([`dsh-cli-demo`](../cli-demo/README.md), [`dsh-acp-demo`](../acp-demo/README.md)) own those choices. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside.
This is the [interface/implementation/consumer seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) raised to the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door.

View File

@@ -47,7 +47,7 @@
- **基于模型的会话标题提供方**组合包挂载带可覆盖示例限制的后备服务5 个词、40 个后备字节、80 个可接受标题字节);叶节点可以恰好选用一个首消息或全消息 LLM 提供方。
- **bash 执行器**:组合包交付 `tool-bash`(消费方 schema叶节点提供 `ctx.bash``bash-local` 或沙箱化实现)。
- **非本地 skill 提供方**:组合包交付 skill 注册表、本地文件系统提供方和 `skill` 工具;部署可以把嵌入式目录或远程目录等其他提供方作为同级插件添加。
- **前端入口与各应用基础设施**:终端 TUI 或 ACPAgent Client Protocol自动化传输以及 `hmr`。应用包([`dsh-tui-demo`](../tui-demo/README.md)、[`dsh-acp-demo`](../acp-demo/README.md))拥有这些选择。`timer` 位于主干中,因为它是共有组件且不写 stdout前端入口拥有 stdout因此留在组合包外。
- **前端入口与各应用基础设施**:终端 TUI 或 ACPAgent Client Protocol自动化传输以及 `hmr`。应用包([`dsh-cli-demo`](../cli-demo/README.md)、[`dsh-acp-demo`](../acp-demo/README.md))拥有这些选择。`timer` 位于主干中,因为它是共有组件且不写 stdout前端入口拥有 stdout因此留在组合包外。
这把[接口/实现/消费方 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) 提升到组合层:组合包拥有共享主干,叶节点拥有后端,应用包拥有前端入口。

View File

@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/examples/tui-demo/README.md
README.md: 058ebe87af5f041bd19fbfb205a97753ccacf6b9
README.zh.md: 43681e9c77cbd77459ec0539b69e0ab73274d2c1

View File

@@ -1,112 +0,0 @@
# @deepseek-ai/dsh-tui-demo
English | [中文](README.zh.md)
The full-screen terminal app bundle: a Cordis plugin that composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), persisted same-session goals, the human-command registry and `/goal` producer, JSONL persistence, keyboard-backed user interaction, a pre-created `main` agent, and [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md). A `cordis.yml` mounts it as one entry; the [`dsh`](../../../apps/cli/README.md) CLI is the front door that boots such a config.
Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and other non-interactive runs. This bundle requires a TTY pair and has no line-oriented fallback.
## What it bakes in
| Plugin | Why it is here |
|---|---|
| `@deepseek-ai/dsh-agent-spine-demo` | Shared services, model-facing tools, and one configured `main` agent |
| `@deepseek-ai/dsh-commands` | Human-only discovery and dispatch consumed by the TUI and command plugins |
| `@deepseek-ai/dsh-command-goal` | Direct `/goal` status and mutation over the spine's persisted-goal stack |
| `@deepseek-ai/dsh-session-persistence-jsonl` | Durable session log under `persistenceRoot` |
| `@deepseek-ai/dsh-session-checkpoint-policy` | Semantic durability barriers before model requests and top-level tool effects, plus completed-step checkpoints |
| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | Combined exact/FTS session queries and bounded `@session` snapshots consumed by the TUI; model-facing query tools remain a leaf opt-in |
| `@deepseek-ai/dsh-user-interaction` | Provider-neutral human question service |
| `@deepseek-ai/dsh-tui` | Full-screen transcript, editor, tool cards, plan, and question overlays |
| `@deepseek-ai/dsh-tool-ask-user` | Model-facing `ask_user_question` tool |
Swappable LLM, bash, filesystem, and other capability providers remain in the leaf config. `@cordisjs/plugin-hmr` also remains a leaf-only development entry because it requires Loader internals.
## Config
| Key | Default | Routed to |
|---|---|---|
| `provider` | required | Configured `main` agent provider |
| `model` | required | Configured `main` agent model |
| `maxParallelToolCalls` | agent-loop default | Bundled loop concurrency cap |
| `persona` | — | System-prompt persona template |
| `toolOrder` | lexicographic | Explicit model-facing tool order |
| `tools` | owner default | Tool presentation mode |
| `dshHome` | owner default | Harness home used by bash and skills |
| `sessionTitle` | spine example limits | Fallback title word/byte limits |
| `skills` | owner defaults | Skill registry, local provider, and tool config |
| `toolBash` | owner defaults | Model-facing bash tool config |
| `toolTasks` | owner defaults | Background-task control-tool config, or `false` |
| `goals` | owner defaults | Persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer |
| `workspaceContext` | required | Workspace-instruction config, or `false` |
| `persistenceRoot` | `./.sessions` | JSONL persistence root and parent of the derived `session-query.db` index |
| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) |
| `sessionReferences` | service defaults | Cross-session candidate and snapshot limits routed to `dsh-session-reference` |
| `welcome` | `ready.` | TUI subtitle |
| `resumeCommand` | — | Exit and no-host fallback command template; the selector itself uses session query and host handoff |
| `ui` | owner defaults | TUI presentation settings such as reasoning, color, and card height |
| `resumeSessionId` | — | Exact persisted session to resume |
Fresh runs mint a `main-session-<uuid>` session id and pass it to both the TUI and configured agent. Resumed runs bind both components to `resumeSessionId`. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. The app composes persistence and session query for `/resume`; an embedding host may additionally provide `tuiResumeHost` for in-place process handoff.
## Front door
This package ships no bin. The [`dsh`](../../../apps/cli/README.md) CLI is the terminal front door: bare `dsh` boots the shipped `examples/tui-agent/cordis.yml` (which mounts this bundle), and `dsh --config <path-to-cordis.yml>` boots an alternate leaf config that mounts it. It loads the optional cwd `.env`, drives the Cordis Loader, and waits for the full plugin tree. The repository installs Loader's optional native helper, so bare package specifiers resolve under plain Node.
## Example leaf
```yaml
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
- id: bash
name: '@deepseek-ai/dsh-bash-local'
- id: tui-agent
name: '@deepseek-ai/dsh-tui-demo'
config:
provider: deepseek
model: deepseek-v4-flash
workspaceContext:
maxBytes: 65536
welcome: 'Coding agent ready.'
ui:
showReasoning: true
```
## Model Experience
### Interactive terminal turn
#### What the model sees
Each non-empty non-command editor submission becomes a user message; a submission during a running turn becomes steering. Slash-command input and output remain human-only, while accepted `/goal` mutations append domain-owned model-visible state. The shared spine contributes the configured persona, workspace instructions, skill catalog, goal controls, and visible tool schemas. TUI rendering itself is not model-visible.
#### Token effect
User, assistant, and tool history grows under the normal session and compaction rules. Headers, cards, plans, Markdown styling, and keybindings add no tokens.
#### KV Cache effect
Append-only while the composed prompt, schemas, route, and retained history prefix remain stable. Composition changes and compaction can invalidate reuse from the first changed token.
### Human-question answer
#### What the model sees
`ask_user_question` retains the tool call and the compact answer or stable interruption error defined by `dsh-tool-ask-user`. The question overlay is terminal-only.
#### Token effect
Only the completed or failed tool result adds retained tokens.
#### KV Cache effect
Append-only; the answer follows the reusable request prefix.
## Known Limitations and Deferred Work
- **TTY-only** — stdin and stdout must both be terminals; automation uses `dsh-cli-demo`.
- **One configured terminal session** — the transcript and editor bind to one exact session id.
- **The app cluster is fixed** — JSONL persistence and ask-user tooling are baked in; different policy requires another composition.
- **Approval is separate** — this app answers `ctx.userInteraction`, not `ctx.approval`; permission prompts require an approval service and answerer.

View File

@@ -1,112 +0,0 @@
# @deepseek-ai/dsh-tui-demo
[English](README.md) | 中文
全屏终端应用组合包:一个 Cordis 插件,组合 [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md)、持久化的同会话目标、人类命令注册表与 `/goal` 生产方、JSONL 持久化、键盘支持的用户交互、预创建的 `main` agent智能体以及 [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md)。一份 `cordis.yml` 将它作为一个 Cordis 配置项挂载;[`dsh`](../../../apps/cli/README.md) CLI命令行界面是启动此类配置的入口。
管道、脚本和其他非交互式运行应使用 [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md)。此组合包需要一对 TTY不提供面向行的回退。
## 内置组件
| 插件 | 设置在此处的原因 |
|---|---|
| `@deepseek-ai/dsh-agent-spine-demo` | 共享服务、面向模型的工具,以及一个已配置的 `main` agent |
| `@deepseek-ai/dsh-commands` | 供 TUI 和命令插件消费、仅面向人类的命令发现与分发 |
| `@deepseek-ai/dsh-command-goal` | 直接在主干的持久化目标栈上提供 `/goal` 状态与变更 |
| `@deepseek-ai/dsh-session-persistence-jsonl` | 位于 `persistenceRoot` 下的持久会话日志 |
| `@deepseek-ai/dsh-session-checkpoint-policy` | 模型请求和顶层工具 effect 前的语义持久性屏障,以及已完成步骤的检查点 |
| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | TUI 消费的组合式精确FTS 会话查询与有界 `@session` 快照;面向模型的查询工具仍由叶节点选用 |
| `@deepseek-ai/dsh-user-interaction` | 与提供方无关的人类问题服务 |
| `@deepseek-ai/dsh-tui` | 全屏 transcript文本记录、编辑器、工具卡片、计划与问题 overlay |
| `@deepseek-ai/dsh-tool-ask-user` | 面向模型的 `ask_user_question` 工具 |
可替换的 LLM大语言模型、bash、文件系统和其他能力提供方仍留在叶节点配置中。`@cordisjs/plugin-hmr` 也仍是仅叶节点使用的开发条目,因为它需要 Loader 内部实现。
## 配置
| 键 | 默认值 | 路由目标 |
|---|---|---|
| `provider` | 必填 | 已配置 `main` agent 的提供方 |
| `model` | 必填 | 已配置 `main` agent 的模型 |
| `maxParallelToolCalls` | agent-loop 默认值 | 组合包内循环的并发上限 |
| `persona` | 无 | 系统提示词 persona 模板 |
| `toolOrder` | 字典序 | 显式的面向模型工具顺序 |
| `tools` | 拥有者默认值 | 工具呈现模式 |
| `dshHome` | 拥有者默认值 | bash 与 skill技能使用的 harness 主目录 |
| `sessionTitle` | 主干示例限制 | 后备标题词数/字节限制 |
| `skills` | 拥有者默认值 | skill 注册表、本地提供方和工具配置 |
| `toolBash` | 拥有者默认值 | 面向模型的 bash 工具配置 |
| `toolTasks` | 拥有者默认值 | 后台任务控制工具配置,或 `false` |
| `goals` | 拥有者默认值 | 持久目标领域与模型工具配置;`false` 会移除目标栈与 `/goal` 生产方 |
| `workspaceContext` | 必填 | Workspace 指令配置,或 `false` |
| `persistenceRoot` | `./.sessions` | JSONL 持久化根目录,以及派生 `session-query.db` 索引的父目录 |
| `persistenceCompression` | `'zstd'` | JSONL 产物编码(`'zstd'` 或原始 `'none'` |
| `sessionReferences` | 服务默认值 | 路由到 `dsh-session-reference` 的跨会话候选项与快照限制 |
| `welcome` | `ready.` | TUI 副标题 |
| `resumeCommand` | 无 | 退出和无宿主回退的命令模板;选择器本身使用会话查询与宿主移交 |
| `ui` | 拥有者默认值 | 推理reasoning、颜色、卡片高度等 TUI 呈现设置 |
| `resumeSessionId` | 无 | 要恢复的确切持久化会话 |
新运行会创建 `main-session-<uuid>` 会话 id并将它同时传给 TUI 与已配置的 agent。恢复运行会将两个组件都绑定到 `resumeSessionId`。TUI 先于主干挂载,因此它可以渲染匹配的配置启动失败,而不会留下空白终端。应用为 `/resume` 组合持久化和会话查询;嵌入宿主还可以提供 `tuiResumeHost`,用于原地移交进程。
## 入口
此包package不交付 bin。[`dsh`](../../../apps/cli/README.md) CLI 是终端入口:裸 `dsh` 启动已交付的 `examples/tui-agent/cordis.yml`(它挂载此组合包),而 `dsh --config <path-to-cordis.yml>` 启动另一个挂载此组合包的叶节点配置。它加载 cwd 下可选的 `.env`,驱动 Cordis Loader并等待完整插件树。仓库安装了 Loader 的可选原生辅助程序,因此裸包说明符可以在纯 Node 下解析。
## 叶节点示例
```yaml
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
- id: bash
name: '@deepseek-ai/dsh-bash-local'
- id: tui-agent
name: '@deepseek-ai/dsh-tui-demo'
config:
provider: deepseek
model: deepseek-v4-flash
workspaceContext:
maxBytes: 65536
welcome: 'Coding agent ready.'
ui:
showReasoning: true
```
## 模型体验
### 交互式终端轮次
#### 模型看到的内容
每次非空、非命令的编辑器提交都会成为用户消息;运行中轮次内的提交成为 steering中途引导。斜杠命令输入和输出仍只面向人类而已接受的 `/goal` 变更会追加领域拥有的模型可见状态。共享主干提供已配置的 persona、workspace 指令、skill 目录、目标控制和可见工具 schema。TUI 渲染本身对模型不可见。
#### Token 影响
用户、assistant 与工具历史按常规会话和压缩compaction规则增长。Header、卡片、计划、Markdown 样式和快捷键不增加 token。
#### KV Cache 影响
只要组合后的提示词、schema、路由和保留历史前缀保持稳定就保持仅追加。组合方式变更与 compaction 可能从第一个变化的 token 起使复用失效。
### 人类问题答案
#### 模型看到的内容
`ask_user_question` 会保留工具调用,以及 `dsh-tool-ask-user` 定义的精简答案或稳定中断错误。问题 overlay 只在终端显示。
#### Token 影响
只有已完成或失败的工具结果会增加保留 token。
#### KV Cache 影响
仅追加;答案跟在可复用请求前缀之后。
## 已知限制与暂缓事项
- **只支持 TTY**stdin 与 stdout 都必须是终端;自动化使用 `dsh-cli-demo`
- **一个已配置的终端会话**transcript 与编辑器绑定到一个确切会话 id。
- **应用集群固定不变**JSONL 持久化与 ask-user 工具内置;不同策略需要另一种组合。
- **批准机制独立存在**:此应用回答 `ctx.userInteraction`,而不是 `ctx.approval`;权限提示需要批准服务和回答方。

View File

@@ -1,76 +0,0 @@
{
"name": "@deepseek-ai/dsh-tui-demo",
"description": "Full-screen TUI app bundle plugin: agent spine + persisted goals + human commands + JSONL persistence + pi-tui front door + pre-created main agent (mounted by the dsh CLI's config)",
"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",
"peerDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-command-goal": "^0.0.1",
"@deepseek-ai/dsh-agent-spine-demo": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1",
"@deepseek-ai/dsh-session-query": "^0.0.1",
"@deepseek-ai/dsh-session-query-sqlite": "^0.0.1",
"@deepseek-ai/dsh-session-reference": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
"@deepseek-ai/dsh-tui": "^0.0.1",
"@deepseek-ai/dsh-tool-ask-user": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"schemastery": "^3.17.0"
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-command-goal": "workspace:^",
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tui": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"cordis": "^4.0.0-rc.7",
"schemastery": "^3.17.0"
}
}

View File

@@ -1,162 +0,0 @@
/**
* Full-screen terminal app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo})
* plus persisted goals, human commands, JSONL persistence, keyboard-backed
* user interaction, and one pre-created agent whose exact session identity the
* TUI drives. Swappable adapters, executors, optional tools, and HMR stay in the leaf. This Loader plugin
* intentionally exposes named exports only; a default export would hide its
* `Config` schema (see docs/postmortem/0001).
* @module @deepseek-ai/dsh-tui-demo
*/
import type { Context } from 'cordis'
import { randomUUID } from 'node:crypto'
import { join } from 'node:path'
import z from 'schemastery'
import { SessionId } from '@deepseek-ai/dsh-session'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
import CommandService from '@deepseek-ai/dsh-commands'
import * as commandGoal from '@deepseek-ai/dsh-command-goal'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import SessionPersistenceJsonl, {
JsonlCompressionSchema,
type JsonlCompression,
} from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import SessionQuerySqlite from '@deepseek-ai/dsh-session-query-sqlite'
import SessionReferenceService, { type Config as SessionReferenceConfig } from '@deepseek-ai/dsh-session-reference'
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
import * as uiTui from '@deepseek-ai/dsh-tui'
export const name = 'tui-demo'
const DEFAULT_PERSISTENCE_ROOT = './.sessions'
// Each front door keeps a complete Loader contract so its deployment config is
// readable without a cross-package facade.
/* jscpd:ignore-start */
/** App config routed to the spine, TUI, configured agent, and JSONL backend. */
export interface Config {
/** Provider route for the `main` agent. */
provider: string
/** Model name for the `main` agent; a matching adapter must be registered. */
model: string
/** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */
maxParallelToolCalls?: number
/** Deployment persona forwarded to the system-prompt plugin. */
persona?: string
/** Explicit model-facing tool order forwarded to the system-prompt plugin. */
toolOrder?: string[]
/** Tool-registry presentation config forwarded through agent-spine-demo. */
tools?: ToolsConfig
/** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
dshHome?: string
/** Fallback session-title limits forwarded through agent-spine-demo. */
sessionTitle?: NonNullable<agentCore.Config['sessionTitle']>
/** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */
persistenceRoot?: string
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
persistenceCompression?: JsonlCompression
/** Cross-session reference discovery and snapshot byte budgets. */
sessionReferences?: SessionReferenceConfig
/** TUI transcript's optional first line; absent renders nothing on start. */
welcome?: string
/**
* Shell command template the TUI prints on exit and lists under `/resume`,
* with `{session}` replaced by the live session id (forwarded to the front
* door). Set it to a command that resumes the session, e.g.
* `dsh --resume {session}`.
*/
resumeCommand?: string
/** Full-screen TUI presentation settings. */
ui?: uiTui.TuiConfig
/** Skill registry, local-provider, and model-facing consumer config. */
skills?: agentCore.SkillConfig
/** Model-facing bash tool config forwarded through agent-spine-demo. */
toolBash?: NonNullable<agentCore.Config['toolBash']>
/** Generic background-task controls forwarded through agent-spine-demo; set false to omit them. */
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
/** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */
goals?: agentCore.GoalConfig | false
/** Persisted session id to resume instead of creating a fresh session. */
resumeSessionId?: string
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
workspaceContext: agentCore.Config['workspaceContext']
}
export const Config: z<Config> = z.object({
provider: z.string().required(),
model: z.string().required(),
maxParallelToolCalls: z.number().step(1).min(1),
persona: z.string(),
// Absent means lexicographic order; schemastery's native array default is [].
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
tools: ToolRegistry.Config,
dshHome: z.string(),
sessionTitle: agentCore.SessionTitleConfigSchema,
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
persistenceCompression: JsonlCompressionSchema,
sessionReferences: SessionReferenceService.Config,
welcome: z.string(),
resumeCommand: z.string(),
ui: uiTui.TuiConfigSchema,
skills: agentCore.SkillConfigSchema,
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
goals: z.union([z.const(false), agentCore.GoalConfigSchema]),
resumeSessionId: z.string(),
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
})
/* jscpd:ignore-end */
/**
* Compose the spine, TUI, JSONL persistence, and user-question tool around one
* exact fresh or resumed session identity. The TUI subscribes to startup
* failures before the spine creates the agent.
* @param ctx - context receiving the app's child plugins.
* @param config - validated app configuration.
*/
export function composeTuiApp(ctx: Context, config: Config): void {
const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId
const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`)
const goals = config.goals ?? {}
const persistenceRoot = config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT
ctx.plugin(CommandService)
if (goals !== false) ctx.plugin(commandGoal)
ctx.plugin(SessionPersistenceJsonl, {
root: persistenceRoot,
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
})
ctx.plugin(sessionCheckpointPolicy)
ctx.plugin(SessionQuerySqlite, { path: join(persistenceRoot, 'session-query.db') })
ctx.plugin(SessionReferenceService, config.sessionReferences ?? {})
ctx.plugin(UserInteractionService)
ctx.plugin(uiTui.TuiPromptService)
ctx.plugin(uiTui, {
...config.ui,
...config.welcome === undefined ? {} : { welcome: config.welcome },
...config.resumeCommand === undefined ? {} : { resumeCommand: config.resumeCommand },
sessionId,
})
ctx.plugin(agentCore, {
...agentCore.pickSpineConfig(config),
goals,
agents: [{
id: SessionId('main'),
provider: config.provider,
model: config.model,
cwd: process.cwd(),
...resumeSessionId === undefined ? { sessionId } : { resumeSessionId: sessionId },
}],
})
ctx.plugin(toolAskUser)
}
/**
* Compose the configured full-screen terminal app.
* @param ctx - context receiving the app's child plugins.
* @param config - validated app configuration.
*/
export function apply(ctx: Context, config: Config): void {
composeTuiApp(ctx, config)
}

View File

@@ -1,30 +0,0 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tui-demo`.
* @module @deepseek-ai/dsh-tui-demo/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-tui-demo'
/** Cordis companion plugin name. */
export const name = 'tui-demo-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this composition-only package delegates mutable state and event streams
* to the agent spine, persistence, and TUI packages that own their checks.
*/
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 */

View File

@@ -1,152 +0,0 @@
import { describe, expect, it } from 'vitest'
import { join } from 'node:path'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import * as tuiAgent from '../src/index.ts'
interface PluginCall {
readonly name: string
readonly config: unknown
}
function recordingContext(): { readonly ctx: Context; readonly calls: PluginCall[] } {
const calls: PluginCall[] = []
const ctx = {
plugin(plugin: { name?: string }, config?: unknown) {
calls.push({ name: plugin.name ?? '', config })
},
} as unknown as Context
return { ctx, calls }
}
describe('dsh-tui-demo app', () => {
it('composes the TUI cluster around one fresh exact session identity', () => {
const { ctx, calls } = recordingContext()
tuiAgent.composeTuiApp(ctx, {
provider: 'mock',
model: 'mock-model',
maxParallelToolCalls: 3,
persona: 'test persona',
toolOrder: ['zulu', TOOL_ORDER_REST],
tools: { mode: 'code' },
dshHome: '/tmp/dsh-home',
persistenceRoot: '/tmp/tui-sessions',
persistenceCompression: 'none',
sessionReferences: {
maxReferences: 2,
candidateLimit: 7,
maxReferenceBytes: 1234,
},
welcome: 'TUI ready',
resumeCommand: 'dsh --resume {session}',
ui: { theme: { color: false }, maxToolOutputLines: 3 },
skills: { tool: { catalogDescriptionMaxLength: 8 } },
toolBash: { enableRunInBackground: false },
toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
workspaceContext: false,
})
expect(calls.map(call => call.name)).toEqual([
'CommandService',
'command-goal',
'SessionPersistenceJsonl',
'session-checkpoint-policy',
'SessionQuerySqlite',
'SessionReferenceService',
'UserInteractionService',
'TuiPromptService',
'ui-tui',
'agent-spine-demo',
'tool-ask-user',
])
expect(calls[0]?.config).toBeUndefined()
expect(calls[2]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' })
expect(calls[4]?.config).toEqual({ path: join('/tmp/tui-sessions', 'session-query.db') })
expect(calls[5]?.config).toEqual({
maxReferences: 2,
candidateLimit: 7,
maxReferenceBytes: 1234,
})
const tuiConfig = calls[8]?.config as { sessionId: string }
expect(tuiConfig).toMatchObject({
welcome: 'TUI ready',
resumeCommand: 'dsh --resume {session}',
theme: { color: false },
maxToolOutputLines: 3,
})
expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/)
const spineConfig = calls[9]?.config as {
readonly agents: Array<Record<string, unknown>>
readonly goals: Record<string, never>
readonly maxParallelToolCalls: number
readonly persona: string
readonly toolOrder: string[]
readonly tools: { mode: string }
}
expect(spineConfig).toMatchObject({
maxParallelToolCalls: 3,
persona: 'test persona',
toolOrder: ['zulu', TOOL_ORDER_REST],
tools: { mode: 'code' },
goals: {},
})
expect(spineConfig.agents[0]).toMatchObject({
id: 'main',
provider: 'mock',
model: 'mock-model',
cwd: process.cwd(),
sessionId: tuiConfig.sessionId,
})
})
it('resumes the configured session and applies runtime defaults', () => {
const { ctx, calls } = recordingContext()
tuiAgent.composeTuiApp(ctx, {
provider: 'mock',
model: 'mock-model',
resumeSessionId: 'persisted-session',
workspaceContext: false,
})
expect(calls[2]?.config).toEqual({ root: './.sessions' })
expect(calls[5]?.config).toEqual({})
// No configured welcome forwards none: the TUI banner sweeps in without a subtitle.
expect(calls[8]?.config).toEqual({ sessionId: 'persisted-session' })
expect((calls[9]?.config as { agents: Array<Record<string, unknown>> }).agents[0]).toMatchObject({
id: 'main',
resumeSessionId: 'persisted-session',
})
})
it('normalizes an empty resume id and routes apply through the same composition', () => {
const { ctx, calls } = recordingContext()
tuiAgent.apply(ctx, {
provider: 'mock',
model: 'mock-model',
resumeSessionId: '',
goals: false,
workspaceContext: false,
})
const tuiConfig = calls[7]?.config as { sessionId: string }
expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/)
expect((calls[8]?.config as { agents: Array<Record<string, unknown>> }).agents[0])
.toMatchObject({ sessionId: tuiConfig.sessionId })
expect(calls.map(call => call.name)).not.toContain('command-goal')
expect(calls[8]?.config).toMatchObject({ goals: false })
})
it('has the namespace-plugin export shape so the Loader keeps its schema', () => {
expect(tuiAgent.name).toBe('tui-demo')
expect(tuiAgent.Config).toBeDefined()
expect('default' in tuiAgent).toBe(false)
expect(typeof tuiAgent.apply).toBe('function')
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(tuiAgent) as Record<string, unknown>
expect(unwrapped).toBe(tuiAgent)
expect(unwrapped.name).toBe('tui-demo')
expect(unwrapped.Config).toBeDefined()
})
})

View File

@@ -1,63 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/session"
},
{
"path": "../../session-query/session-query"
},
{
"path": "../../session-query/session-query-sqlite"
},
{
"path": "../../context/session-reference"
},
{
"path": "../../ui/commands"
},
{
"path": "../../goal/command-goal"
},
{
"path": "../agent-spine-demo"
},
{
"path": "../../context/workspace-context"
},
{
"path": "../../ui/user-interaction"
},
{
"path": "../../ui/tui"
},
{
"path": "../../ui/tool-ask-user"
},
{
"path": "../../session-persistence/session-checkpoint-policy"
},
{
"path": "../../session-persistence/session-persistence-jsonl"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -1,19 +0,0 @@
import { defineConfig } from 'tsdown'
/**
* tui-demo ships the plugin (`index`) and its invariant companion; the CLI
* front door is `dsh` (apps/cli), which mounts this bundle through its config.
* The root tsdown builds only `lib/types/index.js`, so this override adds the
* invariant entry. Declarations come from `tsc -b` (dts: false), matching
* every package.
*/
export default defineConfig({
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/README.md
README.md: d44770f70be16c12f44b78155089e092a3e9bba0
README.zh.md: 2b6878b08be6489dcd510a0a0e0f0e833c2a8014
README.md: 0417a1b8aec36d58ec0f690f397edcf9e015f982
README.zh.md: 46516d187321029ed739d8c071f246bc1755b125

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The host side of the dsh web GUI: the API gateway every client shape shares, and the plain HTTP server it rides on. The browser side lives in [`client/`](../client/README.md); the composed application is [`apps/cli`](../../apps/cli/cordis.yml) serving [`apps/web`](../../apps/web/). All **product** packages.
The host side of the dsh web GUI: the API gateway every client shape shares, and the plain HTTP server it rides on. The browser side lives in [`client/`](../client/README.md); the composed application is [`apps/cli`](../../apps/cli/config/base.cordis.yml) serving [`apps/web`](../../apps/web/). All **product** packages.
| Package | Role | ctx key |
|---|---|---|

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承载它的纯 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合后的应用是 [`apps/cli`](../../apps/cli/cordis.yml),它负责服务 [`apps/web`](../../apps/web/)。全部为**产品**包。
dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承载它的纯 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合后的应用是 [`apps/cli`](../../apps/cli/config/base.cordis.yml),它负责服务 [`apps/web`](../../apps/web/)。全部为**产品**包。
| 包 | 角色 | ctx 键 |
|---|---|---|

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: bf5d0d762365deae83d004e00b94a0ba805d6a6f
README.zh.md: 06c2e37d2590c3c75d0a782bcdf4d98e7b398928
README.md: 0a3d29e41dd0e203c2f576192ea7e88ecb904fac
README.zh.md: b91f4e697ff04eedaaa2fc093229f1c459a1655a

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
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, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/cordis.yml`](../../../apps/cli/cordis.yml).
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, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml).
## Contract layer (`/api`)

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
所有客户端形态共用的 API 网关TS 契约(`src/api/`,不依赖 Node可从浏览器导入、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts``createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`。该包package在设计上与传输方式无关不注册任何路由载体目前为 HTTP未来可以是 IPC自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`apps/cli/cordis.yml`](../../../apps/cli/cordis.yml)。
所有客户端形态共用的 API 网关TS 契约(`src/api/`,不依赖 Node可从浏览器导入、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts``createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`。该包package在设计上与传输方式无关不注册任何路由载体目前为 HTTP未来可以是 IPC自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml)。
## 契约层(`/api`

View File

@@ -62,6 +62,16 @@ export {
type JournalMode,
} from './schema.ts'
/** Boot-context slot for a launcher-owned absolute path to this process's derived query index. */
export const SESSION_QUERY_SQLITE_PATH_KEY = 'launcherSessionQueryPath'
declare module 'cordis' {
interface Context {
/** Launcher-owned absolute path to this process's disposable derived query index. */
launcherSessionQueryPath?: string
}
}
/** Default result page size. */
export const SESSION_QUERY_SQLITE_DEFAULT_LIMIT = 20
/** Maximum accepted result page size. */

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/todo/README.md
README.md: 1e5ae9a1583b9e9d3913fcd1dca7ef11a5f391fe
README.zh.md: a77f788a41353ea547059864dc7cf73ac5025219
README.md: e16d1a3ff413d13d47f9b08a3cfddfedb76b5254
README.zh.md: e3307d4f0acdb3f50db3f1e010286ad11668906a

View File

@@ -8,4 +8,4 @@ The model-facing todo tool. A single **product** package — there is no interfa
|---|---|---|
| `tool-todo/` | Model-facing `todo_write` tool; writes the whole list to the session log (`todo/write`) | (registers on `ctx.tools`) |
The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs such as the [TUI app](../examples/tui-demo) and the host/client runtime render the durable list from session events.
The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs such as the [TUI app](../ui/tui) and the host/client runtime render the durable list from session events.

View File

@@ -8,4 +8,4 @@
|---|---|---|
| `tool-todo/` | 面向模型的 `todo_write` 工具;将完整列表写入会话日志(`todo/write` | (注册到 `ctx.tools` |
列表存在于事件溯源会话日志中(`SessionEventMap['todo/write']`,由 [`dsh-session`](../core/session) 拥有);本包是追加快照的轻量消费方。[TUI 应用](../examples/tui-demo)等 UI 以及宿主/客户端运行时会根据会话事件渲染该持久化列表。
列表存在于事件溯源会话日志中(`SessionEventMap['todo/write']`,由 [`dsh-session`](../core/session) 拥有);本包是追加快照的轻量消费方。[TUI 应用](../ui/tui)等 UI 以及宿主/客户端运行时会根据会话事件渲染该持久化列表。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/todo/tool-todo/README.md
README.md: b05ef43e7137dcf5678b1f1ad6d8c00b8a43baef
README.zh.md: 88a9f5d69ff52dcedbd8f27a4ace5fde3e0dc5a2
README.md: 91f4bc6abd0c08f44f0a0a50393e4cdd5ddada70
README.zh.md: b9582307ff5590bf34be91b776fa06841a68b41d

View File

@@ -20,7 +20,7 @@ Beyond the schema's type/required/enum checks, `execute` rejects an empty or dup
## Rendering
The canonical result is `{ todos, counts: { pending, inProgress, completed } }`; its Native renderer returns the compact update acknowledgement. The tool also writes the full `todo/write` session event. UIs subscribe to the event stream and render that durable list themselves: the [TUI app](../../examples/tui-demo) and the [web client](../../client/ui-conversation) show a plan strip (plus a dedicated web tool row) off the standing plan — latest `todo/write` with no later `turn/start` ([display](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md), [lifetime](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md)).
The canonical result is `{ todos, counts: { pending, inProgress, completed } }`; its Native renderer returns the compact update acknowledgement. The tool also writes the full `todo/write` session event. UIs subscribe to the event stream and render that durable list themselves: the [TUI app](../../ui/tui) and the [web client](../../client/ui-conversation) show a plan strip (plus a dedicated web tool row) off the standing plan — latest `todo/write` with no later `turn/start` ([display](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md), [lifetime](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md)).
## Session projection

View File

@@ -20,7 +20,7 @@
## 渲染
规范结果为 `{ todos, counts: { pending, inProgress, completed } }`;其 Native 渲染器返回精简的更新确认。工具还会写入完整 `todo/write` 会话事件。UI 订阅事件流,并自行渲染该持久化列表:[TUI 应用](../../examples/tui-demo)与 [web 客户端](../../client/ui-conversation)基于当前有效计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`显示计划条web 另有专属工具行)([展示](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md)、[生命周期](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md))。
规范结果为 `{ todos, counts: { pending, inProgress, completed } }`;其 Native 渲染器返回精简的更新确认。工具还会写入完整 `todo/write` 会话事件。UI 订阅事件流,并自行渲染该持久化列表:[TUI 应用](../../ui/tui)与 [web 客户端](../../client/ui-conversation)基于当前有效计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`显示计划条web 另有专属工具行)([展示](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md)、[生命周期](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md))。
## 会话投影

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md
README.md: 0282d3e9559d55c3fe5b07df133747750c06ebad
README.zh.md: b7121bbd288cd6e3f9ef2301de6018ceb380eb06
README.md: 4d8c7de65515a251f227075c7baf041fc1b210c8
README.zh.md: d9b69a2b102a60685524288f75edeaabb21802db

View File

@@ -10,9 +10,10 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure |
| `assertEntriesActive(ctx, binName)` | Throw when a settled enabled fiber is not ACTIVE, including missing injected services for PENDING entries |
| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount (e.g. `ctx.provide(RESUME_SESSION_ID_KEY, id)`), then mount the Loader/include tree, await it, assert entries loaded, and return the root context |
| `RESUME_SESSION_ID_KEY` | Context key a bin sets through `boot`'s `prepare` hook to hand a resume session id to the booted config; the config reads it as the bare identifier `resumeSessionId` in a `!!js` expression, so resuming needs no environment variable |
| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and ACTIVE, and return the root context |
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
@@ -24,10 +25,10 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](..
## Personal config
A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's TUI surface ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files:
A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the official `dsh` surfaces ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files:
- **`.env`** — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient environment > project `.env` > personal `.env`.
- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as an include entry's `patches` (the committed Code Mode overlay is the template): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount — so a personal `apiKey` can reference the personal `.env`. A patch naming an entry id absent from the booted tree is skipped with a loader warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file.
- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount — so a personal `apiKey` can reference the personal `.env`. A patch naming an entry id absent from the booted tree is a silent no-op. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file.
Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures.
@@ -45,4 +46,3 @@ No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSec
- **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection.
- **Environment loading is cwd-scoped and optional** — the helper loads one `.env` file and warns on failure; it does not search parents, merge profiles, or validate required variables.
- **Personal config is patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a personal override restates the base fields it keeps.
- **Personal patches see only the booted file's own entries** — an overlay leaf that reaches its base through a nested include entry (the Code Mode configs) resolves personal patch ids against the overlay's top-level entries, not the included subtree.

View File

@@ -10,9 +10,10 @@
| `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr |
| `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) |
| `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 |
| `assertEntriesActive(ctx, binName)` | 树结算后,如果已启用的 fiber 未处于 ACTIVE 状态,则抛出异常;对于 PENDING 条目还会列出缺失的注入服务 |
| `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,在插件挂载前执行可选的宿主准备操作(例如 `ctx.provide(RESUME_SESSION_ID_KEY, id)`),再挂载 Loader/include 树并等待其结算,断言所有条目均已加载,最后返回根上下文 |
| `RESUME_SESSION_ID_KEY` | bin 通过 `boot``prepare` 钩子设置的上下文键,用于把要恢复的会话 id 交给已启动配置;配置以裸标识符 `resumeSessionId``!!js` 表达式中读取它,因此恢复操作无需环境变量 |
| `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文并安装 Loader在配置树条目挂载前执行可选的宿主准备操作`prepare` 可以使用 Loader也可以提供由启动器拥有的上下文插槽例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载且处于 ACTIVE 状态,最后返回根上下文 |
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent智能体自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber因此开发环境 HMR热模块替换重新加载系统提示词后它会消失直至下次启动 |
| `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
@@ -24,10 +25,10 @@
## 个人配置
开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由 `dsh` CLI命令行界面的 TUI 界面([`apps/cli`](../../../apps/cli/README.md)使用demo bin 会原样启动仓库中提交的树。这里有两个可选文件:
开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由官方 `dsh` 界面([`apps/cli`](../../../apps/cli/README.md)使用demo bin 会原样启动仓库中提交的树。这里有两个可选文件:
- **`.env`**:在调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境中的值 > 项目 `.env` > 个人 `.env`
- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch语义与 include 条目的 `patches` 相同(以仓库提交的 Code Mode overlay 为模板):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值,因此个人 `apiKey` 可以引用个人 `.env`。如果 patch 指定的条目 id 不在已启动树中,Loader 会发出警告并跳过。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay请使用 `[]` 或删除该文件。
- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值,因此个人 `apiKey` 可以引用个人 `.env`。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay请使用 `[]` 或删除该文件。
子进程测试 launcher 会把 `DSH_HOME` 指向逐测试隔离的目录,确保开发者的个人 overlay 不会泄漏到 fixture测试前置数据中。
@@ -45,4 +46,3 @@
- **快照回放替换仅识别特定 basename**:只有以 `cordis.yml``cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。
- **环境加载局限于 cwd 且为可选操作**helper 只加载一个 `.env` 文件,并在失败时发出警告;它不会搜索父目录、合并 profile 或验证必需变量。
- **个人配置采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此个人覆盖必须重述需要保留的基础字段。
- **个人 patch 只能看到已启动文件自身的条目**:如果 overlay 叶子通过嵌套 include 条目访问其基础配置(例如 Code Mode 配置),个人 patch id 只会在 overlay 的顶层条目中解析,不会进入被 include 的子树。

View File

@@ -10,7 +10,7 @@ import { pathToFileURL } from 'node:url'
import { readFileSync } from 'node:fs'
import { basename, dirname, join, resolve } from 'node:path'
import * as yaml from 'js-yaml'
import { Context } from 'cordis'
import { Context, type FiberState } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
@@ -94,20 +94,56 @@ export function loadPersonalPatches(
if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined
throw new Error(`${binName}: failed to read personal patches ${file}: ${String(error)}`)
}
return parsePatchList(binName, file, content, 'personal patches')
}
/**
* Load a required overlay patch list: a surface overlay (`tui.cordis.yml`) or a
* `--config <path>` overlay applied over the shared base. Same file format as
* {@link loadPersonalPatches}, but a missing file throws, because the caller
* named this file — its absence is a misconfiguration, not "no overlay".
* @param binName - the diagnostic prefix on the thrown error.
* @param file - absolute path of the overlay file.
* @returns the parsed patch list.
*/
export function loadOverlayPatches(binName: string, file: string): PatchOptions[] {
let content: string
try {
content = readFileSync(file, 'utf8')
} catch (error) {
throw new Error(`${binName}: failed to read overlay ${file}: ${String(error)}`)
}
return parsePatchList(binName, file, content, 'overlay')
}
/**
* Parse one loader patch list: a top-level YAML array of
* `@cordisjs/plugin-include` `PatchOptions` (id-targeted config overrides and
* `insert` lists, `!!js` expressions allowed). Every shape failure throws,
* because a patch file that cannot be applied at all is a misconfiguration; a
* single patch whose target row is absent stays a per-entry Loader warning, so
* one overlay shared across surfaces does not have to match every tree.
* @param binName - the diagnostic prefix on the thrown error.
* @param file - the source path, quoted in errors.
* @param content - the file's text.
* @param label - what to call this list in errors (`personal patches`, `overlay`).
* @returns the parsed patch list.
*/
function parsePatchList(
binName: string, file: string, content: string, label: string,
): PatchOptions[] {
let parsed: unknown
try {
parsed = yaml.load(content, { schema: personalPatchesSchema })
} catch (error) {
throw new Error(`${binName}: failed to parse personal patches ${file}: ${String(error)}`)
throw new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`)
}
if (!Array.isArray(parsed)) {
throw new Error(`${binName}: personal patches ${file} must be a top-level YAML array of loader patch entries`)
throw new Error(`${binName}: ${label} ${file} must be a top-level YAML array of loader patch entries`)
}
// A present personal config that cannot apply is a misconfiguration and must
// fail loud here — the include only warns per entry at mount.
parsed.forEach((entry, index) => {
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
throw new Error(`${binName}: personal patches entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`)
throw new Error(`${binName}: ${label} entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`)
}
})
return parsed as PatchOptions[]
@@ -156,16 +192,30 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
}
}
/** Runtime mirrors for Cordis's erased const-enum fiber states. */
const FIBER_ACTIVE = 2 as FiberState.ACTIVE
const FIBER_PENDING = 0 as FiberState.PENDING
/**
* Context key a bin sets through {@link boot}'s `prepare` hook to hand a resume
* session id to the booted config: `ctx.provide(RESUME_SESSION_ID_KEY, id)`
* makes `id` readable as the bare identifier `resumeSessionId` in a config
* `!!js` expression. The value is the bin's already-parsed id (or `undefined`),
* so resuming a session needs no environment variable. A bin that never
* provides it leaves the identifier undeclared, so configs read it defensively
* (`typeof resumeSessionId === 'string' ? resumeSessionId : undefined`).
* Reject enabled Loader entries whose fibers did not reach ACTIVE after settle.
* @param ctx - The settled application root.
* @param binName - Diagnostic prefix.
*/
export const RESUME_SESSION_ID_KEY = 'resumeSessionId'
export function assertEntriesActive(ctx: Context, binName: string): void {
const failures: string[] = []
for (const entry of ctx.loader.entries()) {
if (entry.fiber === undefined || entry.disabled || entry.fiber.state === FIBER_ACTIVE) continue
if (entry.fiber.state === FIBER_PENDING) {
const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined)
failures.push(`${entry.options.name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
} else {
failures.push(`${entry.options.name}: fiber state ${String(entry.fiber.state)}`)
}
}
if (failures.length > 0) {
throw new Error(`${binName}: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
}
}
/**
* Boot the Loader against `absoluteConfigPath` and return only after the whole
@@ -183,7 +233,7 @@ export const RESUME_SESSION_ID_KEY = 'resumeSessionId'
* (see {@link resolveConfigPath}).
* @param patches - optional overlay patches applied over the included tree
* (see {@link loadPersonalPatches}); an empty list mounts none.
* @param prepare - optional host setup run against the root context before any Loader entry mounts.
* @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts.
* @returns the root context once every entry has started.
*/
export async function boot(
@@ -193,10 +243,10 @@ export async function boot(
prepare?: (ctx: Context) => Promise<void> | void,
): Promise<Context> {
const ctx = new Context()
await prepare?.(ctx)
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
await prepare?.(ctx)
await ctx.loader.create({
name: 'cordis:include',
config: {
@@ -206,6 +256,7 @@ export async function boot(
})
await ctx.loader.await()
assertEntriesLoaded(ctx, binName)
assertEntriesActive(ctx, binName)
return ctx
}

View File

@@ -5,8 +5,8 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import {
addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
installFailLoud, loadEnv, resolveConfigPath, type FailLoudProcess,
addHarnessSourceSection, assertEntriesActive, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
} from '../src/index.ts'
const NAME = 'dsh-test-bin'
@@ -157,6 +157,25 @@ describe('assertEntriesLoaded', () => {
})
})
describe('loadOverlayPatches', () => {
it('loads expressions and rejects missing, malformed, non-array, and non-mapping overlays', () => {
const dir = tmp()
const valid = join(dir, 'valid.yml')
writeFileSync(valid, '- id: target\n config:\n value: !!js process.env.VALUE\n')
expect(loadOverlayPatches(NAME, valid)).toEqual([{ id: 'target', config: { value: { __jsExpr: 'process.env.VALUE' } } }])
expect(() => loadOverlayPatches(NAME, join(dir, 'missing.yml'))).toThrow(`${NAME}: failed to read overlay`)
const malformed = join(dir, 'malformed.yml')
writeFileSync(malformed, ': bad')
expect(() => loadOverlayPatches(NAME, malformed)).toThrow(`${NAME}: failed to parse overlay`)
const mapping = join(dir, 'mapping.yml')
writeFileSync(mapping, 'id: target\n')
expect(() => loadOverlayPatches(NAME, mapping)).toThrow('must be a top-level YAML array')
const scalar = join(dir, 'scalar.yml')
writeFileSync(scalar, '- scalar\n')
expect(() => loadOverlayPatches(NAME, scalar)).toThrow('entry 1')
})
})
describe('boot', () => {
it('boots a leaf config through the real Loader and settles the tree', async () => {
const dir = tmp()
@@ -176,7 +195,11 @@ describe('boot', () => {
writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
const prepared: Context[] = []
const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => { prepared.push(hostCtx) })
const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => {
expect(hostCtx.loader).toBeDefined()
expect([...hostCtx.loader.entries()]).toEqual([])
prepared.push(hostCtx)
})
try {
expect(prepared).toEqual([ctx])
} finally {
@@ -189,6 +212,33 @@ describe('boot', () => {
writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
})
it('rejects a settled tree with a pending inject and names every missing service', async () => {
const dir = tmp()
writeFileSync(join(dir, 'waiting.mjs'), "export const inject = ['alpha', 'beta']\nexport function apply() {}\n")
writeFileSync(join(dir, 'cordis.yml'), '- id: waiting\n name: ./waiting.mjs\n')
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow('./waiting.mjs: pending (waiting for services: alpha, beta)')
})
it('uses singular diagnostics for one missing pending dependency', () => {
const ctx = {
loader: { entries: () => [{ disabled: false, options: { name: 'waiting' }, fiber: { state: 0, inject: { alpha: {} } } }] },
get: () => undefined,
} as unknown as Context
expect(() =>{ assertEntriesActive(ctx, NAME) }).toThrow('waiting: pending (waiting for service: alpha)')
})
it('reports unknown pending dependencies and unexpected fiber states', () => {
const entries = [
{ disabled: false, options: { name: 'unknown' }, fiber: { state: 0, inject: {} } },
{ disabled: false, options: { name: 'failed' }, fiber: { state: 3, inject: {} } },
]
const ctx = {
loader: { entries: () => entries },
get: () => undefined,
} as unknown as Context
expect(() =>{ assertEntriesActive(ctx, NAME) }).toThrow(`${NAME}: 2 entries did not activate\nunknown: pending (waiting for services: unknown)\nfailed: fiber state 3`)
})
})
describe('addHarnessSourceSection', () => {

View File

@@ -126,3 +126,52 @@ describe('include refresh with overlay patches', () => {
}
})
})
describe('include patches layered over one base', () => {
it('lets a later patch configure or disable a row an earlier patch inserted', async () => {
// The surface/`--config`/personal composition: `dsh` includes one shared
// base and applies each source as its own patch list at the SAME include
// level, because patches never cross an include boundary. A later layer
// must therefore be able to reach a row an earlier layer inserted —
// otherwise every surface-only row (the whole TUI front door) would be
// invisible to the user's `~/.dsh/config.yaml`.
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-layered-'))
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
writeFileSync(join(dir, 'base.yml'), '- id: shared\n name: ./noop.mjs\n config:\n value: base\n')
writeFileSync(join(dir, 'cordis.yml'), [
'- id: base',
" name: 'cordis:include'",
' config:',
' path: ./base.yml',
' patches:',
// Layer 1 (a surface overlay): patch a base row and add two of its own.
' - id: shared',
' config:',
' value: surface',
' - insert:',
' - id: surface-kept',
' name: ./noop.mjs',
' config:',
' value: surface-default',
' - id: surface-dropped',
' name: ./noop.mjs',
// Layer 2 (the user): reconfigure one inserted row and disable the other.
' - id: surface-kept',
' config:',
' value: personal',
' - id: surface-dropped',
' disabled: true',
'',
].join('\n'))
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
try {
expect(entryConfig(ctx, 'shared')).toEqual({ value: 'surface' })
expect(entryConfig(ctx, 'surface-kept')).toEqual({ value: 'personal' })
const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'surface-dropped')
expect(dropped?.options.disabled).toBe(true)
expect(dropped?.fiber).toBeUndefined()
} finally {
await ctx.fiber.dispose()
}
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/tui/README.md
README.md: 99d76d21828bc6b1eb0220e11362885652b3cefd
README.zh.md: 71b3b0546fed70a89f34a2ca4eee423be5909afd
README.md: dc8af7796d62ca1588423dc63aa592fd3308d218
README.zh.md: 8e5fb632d8903c1915015396af20a791a9a3ab70

View File

@@ -75,7 +75,7 @@ A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY
fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist']
```
Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops extension admission, unloads the `ctx.tui` provider and its dependent plugins, aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR.
Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops extension admission, unloads the `ctx.tui` provider and its dependent plugins, aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR. A user exit disposes the application root so sibling resources close, then exits; a five-second fallback prevents one stuck disposer from trapping the process.
## Color

View File

@@ -75,7 +75,7 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist']
```
任一进程流不是 TTY 时,启动会在挂载前失败。组合 app 必须先挂载 TUI再挂载由配置创建的 agent使入口能够观察 `agent-loop/config-start-failed`;完全匹配会话的失败会在全屏模式启动前写出并以状态 1 退出而不是留下空白终端。dispose资源释放会停止接收扩展请求卸载 `ctx.tui` 提供方及其依赖插件,中止运行中的命令,移除 TUI 定义,停止 loader拒绝待处理问题排空终端输入恢复终端状态注销事件 listener 和用户交互提供方,并且绝不会在 HMR 期间退出替换进程。
任一进程流不是 TTY 时,启动会在挂载前失败。组合 app 必须先挂载 TUI再挂载由配置创建的 agent使入口能够观察 `agent-loop/config-start-failed`;完全匹配会话的失败会在全屏模式启动前写出并以状态 1 退出而不是留下空白终端。dispose资源释放会停止接收扩展请求卸载 `ctx.tui` 提供方及其依赖插件,中止运行中的命令,移除 TUI 定义,停止 loader拒绝待处理问题排空终端输入恢复终端状态注销事件 listener 和用户交互提供方,并且绝不会在 HMR 期间退出替换进程。用户退出会先 dispose 应用根上下文以关闭同级资源,再退出进程;五秒兜底可避免某个卡住的 disposer 困住进程。
## 颜色

View File

@@ -29,7 +29,13 @@ import type { ChannelNotice, ChatChannelDeps } from './channel.ts'
export interface ResumeControllerDeps extends ChatChannelDeps, ChannelNotice {
readonly agent: Agent
readonly runtime: TuiRuntime
readonly sessionQuery: SessionQueryService | undefined
/**
* The optional session-query service, re-read at each use. `sessionQuery` is
* mounted by an independent plugin, and a flat config tree gives no ordering
* guarantee between it and this front door, so a value captured once at
* construction can be `undefined` even though the service arrives moments later.
*/
readonly sessionQuery: (this: void) => SessionQueryService | undefined
readonly ui: TUI
readonly editor: HintEditor
/** Current agent status, re-read at each resume precondition point. */
@@ -74,9 +80,11 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
events: live.events.map(event => structuredClone(event)),
}
} else {
/* v8 ignore next -- caller checks the optional service before mapping records */
if (sessionQuery === undefined) throw new Error('session query is unavailable')
snapshot = await sessionQuery.readSession(record.header.id)
const readQuery = sessionQuery()
/* v8 ignore start -- caller proves the optional service before mapping records */
if (readQuery === undefined) throw new Error('session query is unavailable')
/* v8 ignore stop */
snapshot = await readQuery.readSession(record.header.id)
}
return summarizeResumeCandidate(
record,
@@ -104,11 +112,13 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
* resolve the exact identity and workspace the host will re-exec into.
*/
const preflightResume = async (sessionId: SessionId): Promise<{ id: SessionId; cwd: string }> => {
/* v8 ignore next -- only showResume can call this closure, after proving the optional service exists */
if (sessionQuery === undefined) throw new Error('Resume is unavailable: session query is not mounted.')
const query = sessionQuery()
/* v8 ignore start -- showResume alone calls this after proving the optional service exists */
if (query === undefined) throw new Error('Resume is unavailable: session query is not mounted.')
/* v8 ignore stop */
const initialStatus = deps.agentStatus()
if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`)
const record = (await sessionQuery.listSessions()).find(candidate => candidate.header.id === sessionId)
const record = (await query.listSessions()).find(candidate => candidate.header.id === sessionId)
if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`)
const candidate = await readResumeCandidate(
record,
@@ -177,13 +187,14 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
deps.appendNotice('Resume requires the current turn to finish or be cancelled first.', 'warning')
return
}
if (sessionQuery === undefined) {
const listQuery = sessionQuery()
if (listQuery === undefined) {
deps.appendNotice('Resume is not available: session query is not mounted.', 'warning')
return
}
const scan = ++resumeScan
void resumeOverlay?.close()
void sessionQuery.listSessions().then(async (records) => {
void listQuery.listSessions().then(async (records) => {
if (deps.isDisposed() || scan !== resumeScan) return
// Every workspace in the store is summarized; the picker owns the
// current-workspace/all-workspaces scope split over the whole set.

View File

@@ -19,7 +19,7 @@ import {
type SlashCommand,
type TerminalColorScheme,
} from '@earendil-works/pi-tui'
import { Service, type Context, type Fiber } from 'cordis'
import { Service, type Context, type Fiber, type FiberState } from 'cordis'
import {
assembleContextFor,
installAgentLlmTarget,
@@ -56,6 +56,7 @@ import {
TuiExtensionServiceImpl,
TuiOverlayManager,
} from './extension/overlay-manager.ts'
import {
parseTuiPromptTemplate,
renderTuiPromptTemplate,
@@ -171,6 +172,9 @@ export type {
TuiViewport,
} from './extension/types.ts'
/** First terminal Cordis state: FAILED, DISPOSED, and UNLOADING are unusable. */
const FIBER_FAILED = 3 as FiberState.FAILED
declare module 'cordis' {
interface Context {
/** Terminal-only interaction service, available only while a TUI is mounted. */
@@ -183,8 +187,6 @@ declare module 'cordis' {
tuiGoodbyeMessage: string | undefined
/** Skill the launcher wants auto-invoked as the fresh session's first turn; absent leaves it to the user. */
tuiInitialSkill: string | undefined
/** Launcher-owned session-store root the app bundle defaults to; absent keeps the bundle's project-local default. */
launcherSessionsRoot: string | undefined
}
}
@@ -229,16 +231,6 @@ export const TUI_GOODBYE_MESSAGE_KEY = 'tuiGoodbyeMessage'
*/
export const INITIAL_SKILL_KEY = 'tuiInitialSkill'
/**
* Context key a launcher sets before any Loader entry mounts
* (`ctx.provide(SESSIONS_ROOT_KEY, root)`) to supply its session-store root as
* the app bundle's default persistence root. Shared-store policy (one store
* across every cwd) belongs to the launcher — the dsh CLI resolves it under the
* Harness home — never to a plugin; a bundle without this slot keeps its own
* project-local default, and an explicit `persistenceRoot` config still wins.
*/
export const SESSIONS_ROOT_KEY = 'launcherSessionsRoot'
/**
* Optional terminal-local interaction service provided by one mounted TUI.
*
@@ -307,7 +299,6 @@ export function createTuiChat(
const sessionId = SessionId(config.sessionId ?? 'main')
const agent = ctx.agents.get(sessionId)
if (agent === undefined) throw new Error(`ui-tui: session "${sessionId}" is not running`)
const sessionQuery = ctx.get('sessionQuery')
const resolved = resolveTuiConfig(config)
const palette = createPalette(resolved.theme.color)
const mdTheme = markdownTheme(palette)
@@ -854,7 +845,14 @@ export function createTuiChat(
resolved,
palette,
overlayManager,
sessionQuery,
// Optional and independently mounted. Cordis transiently leaves this sibling
// non-ACTIVE during command callbacks, so the non-strict read is intentional;
// terminal fiber states still exclude failed, closing, and closed providers.
sessionQuery: () => {
const implementation = ctx.reflect._getImpl('sessionQuery', false)
if (implementation === undefined || implementation.fiber.state >= FIBER_FAILED) return undefined
return ctx.get('sessionQuery', false)
},
ui,
editor,
appendNotice,
@@ -1663,9 +1661,35 @@ export function mountTui(ctx: Context, config: Config, runtime: TuiRuntime): voi
if (existing !== undefined) start(existing)
}
const ROOT_DISPOSE_TIMEOUT_MS = 5_000
/**
* Dispose the whole application before process exit, with a bounded fallback.
* @param ctx - The TUI plugin context whose root owns sibling resources.
* @param code - Process status to report.
* @param exit - Exit boundary, replaceable by tests.
*/
export function disposeRootAndExit(
ctx: Context,
code: number,
exit: (status: number) => void = (status) => { process.exit(status) },
): void {
let exited = false
const exitOnce = (): void => {
if (exited) return
exited = true
exit(code)
}
const timeout = setTimeout(exitOnce, ROOT_DISPOSE_TIMEOUT_MS)
void ctx.root.fiber.dispose().then(
() => { clearTimeout(timeout); exitOnce() },
() => { clearTimeout(timeout); exitOnce() },
)
}
/** Cordis entry point using the process terminal; explicit TUI composition requires a TTY pair. */
/* v8 ignore start -- production process wiring; fake-terminal tests cover mountTui/createTuiChat,
and the tui-agent PTY smoke covers the real entry */
and apps/cli PTY smokes cover the real entry */
export function apply(ctx: Context, config: Config): void {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
throw new Error('ui-tui: both stdin and stdout must be TTYs; use the one-shot @deepseek-ai/dsh-cli-demo app for pipes')
@@ -1685,7 +1709,7 @@ export function apply(ctx: Context, config: Config): void {
initialSkill === undefined ? {} : { initialSkill },
), {
terminal: new ProcessTerminal(),
exit: code => process.exit(code),
exit: (code) => { disposeRootAndExit(ctx, code) },
...resumeHost === undefined ? {} : { handoffResume: (sessionId, cwd) => resumeHost.handoff(sessionId, cwd) },
...goodbyeMessage === undefined ? {} : { goodbyeMessage },
})

View File

@@ -29,6 +29,7 @@ import SessionReferenceService, { formatSessionReferenceMention } from '@deepsee
import type {} from '@deepseek-ai/dsh-llm-retry'
import {
createTuiChat,
disposeRootAndExit,
FILE_REFERENCE_PROMPT,
mountTui,
renderSkillInvocation,
@@ -502,6 +503,41 @@ describe('goodbye message and /resume', () => {
await dispose(result)
})
it('allows a transient session-query state but rejects a terminal state', async () => {
let queryCtx: Context | undefined
let listCalls = 0
const result = await setup({
cwd: '/workspace',
async configureContext(ctx) {
await ctx.plugin({
apply(child: Context) {
queryCtx = child
child.provide('sessionQuery', {
listSessions: async () => { listCalls++; return [] },
} as never)
},
})
},
})
if (queryCtx === undefined) throw new Error('query provider did not mount')
const activeState = queryCtx.fiber.state
queryCtx.fiber.state = 0
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
expect(listCalls).toBe(1)
result.terminal.send('\u001B')
await tick()
queryCtx.fiber.state = 5
result.terminal.send('/resume')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('session query is not mounted')
expect(listCalls).toBe(1)
queryCtx.fiber.state = activeState
await dispose(result)
})
it('keeps persisted query records readable without a persistence service', async () => {
const target = header('query-only-persisted', 10, '/workspace')
const result = await setup({
@@ -4962,6 +4998,59 @@ describe('TUI extension service', () => {
})
})
describe('application exit', () => {
it('disposes the root fiber rather than only the TUI child before exiting', async () => {
const rootDispose = vi.fn(() => Promise.resolve())
const childDispose = vi.fn(() => Promise.resolve())
const ctx = {
root: { fiber: { dispose: rootDispose } },
fiber: { dispose: childDispose },
} as unknown as Context
const exit = vi.fn()
disposeRootAndExit(ctx, 7, exit)
await Promise.resolve()
expect(rootDispose).toHaveBeenCalledOnce()
expect(childDispose).not.toHaveBeenCalled()
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(7)
})
it('forces exit when root disposal does not settle', async () => {
vi.useFakeTimers()
try {
let settle!: () => void
const disposal = new Promise<void>((resolve) => { settle = resolve })
const ctx = {
root: { fiber: { dispose: () => disposal } },
} as unknown as Context
const exit = vi.fn()
disposeRootAndExit(ctx, 9, exit)
await vi.advanceTimersByTimeAsync(4_999)
expect(exit).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(9)
settle()
await disposal
await Promise.resolve()
expect(exit).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
it('exits after a rejected root disposal without an unhandled rejection', async () => {
const ctx = {
root: { fiber: { dispose: () => Promise.reject(new Error('cleanup failed')) } },
} as unknown as Context
const exit = vi.fn()
disposeRootAndExit(ctx, 5, exit)
await Promise.resolve()
await Promise.resolve()
expect(exit).toHaveBeenCalledWith(5)
})
})
describe('terminal mounting', () => {
it('starts immediately when the configured agent already exists', async () => {
const ctx = new Context()