From 42e3cceb6489258c142f51bb3ad54d4e6fc5c2d7 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 28 Jul 2026 22:19:13 +0800 Subject: [PATCH 01/77] feat(paths): add resolveSessionsRoot as the one shared session-store root Every surface that persists or lists sessions resolves one directory under the Harness home, so history is shared across working directories instead of scattered per project. --- packages/util/paths/README.i18n.yaml | 4 ++-- packages/util/paths/README.md | 4 ++++ packages/util/paths/README.zh.md | 8 ++++++-- packages/util/paths/src/index.ts | 21 +++++++++++++++++++++ packages/util/paths/tests/paths.spec.ts | 11 +++++++++++ 5 files changed, 44 insertions(+), 4 deletions(-) diff --git a/packages/util/paths/README.i18n.yaml b/packages/util/paths/README.i18n.yaml index a57d18b601..f21ee7f88b 100644 --- a/packages/util/paths/README.i18n.yaml +++ b/packages/util/paths/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/paths/README.md -README.md: b28e684f3183d739c8e229a9b341801dbf345d86 -README.zh.md: ab4e8123d19fd56749e3e7a0d59e8cd6bea0c3d0 +README.md: 837ee0cdf7687fac58058e04ff5971002124038a +README.zh.md: d34eeb023a506d5c07f32638ac74698b3a913efa diff --git a/packages/util/paths/README.md b/packages/util/paths/README.md index b28e684f31..837ee0cdf7 100644 --- a/packages/util/paths/README.md +++ b/packages/util/paths/README.md @@ -16,6 +16,10 @@ Shared filesystem path helpers for DeepSeek Harness user data. `expandHomePath()` expands `~`, `~/...`, and Windows-style `~\...` prefixes against the operating-system home directory. It leaves non-tilde paths and `~user/...` untouched. +## Session store + +`resolveSessionsRoot()` resolves the shared session-store root under the Harness home, by the same precedence as `resolveDshHome()`. `SESSIONS_DIR_NAME` owns its directory name: `sessions`. Every surface that persists sessions resolves this one directory, so history is shared across working directories instead of scattered per project; a persistence backend may still partition inside it. Two surfaces resolving different roots would silently split one user's history into disjoint stores, which is why the location is owned here rather than joined per caller. + This package is intentionally small and harness-dep-free so product packages can share user-data path conventions without depending on one another. ## Known Limitations and Deferred Work diff --git a/packages/util/paths/README.zh.md b/packages/util/paths/README.zh.md index ab4e8123d1..d34eeb023a 100644 --- a/packages/util/paths/README.zh.md +++ b/packages/util/paths/README.zh.md @@ -16,9 +16,13 @@ DeepSeek Harness 用户数据的共享文件系统路径辅助工具。 `expandHomePath()` 使用操作系统主目录展开 `~`、`~/...` 和 Windows 风格的 `~\...` 前缀。它会保留非波浪号路径和 `~user/...` 原样不变。 -该包(package)刻意保持规模小且不依赖 harness,以便产品包共享用户数据路径约定,而不必彼此依赖。 +## 会话存储 -## 已知限制与暂缓事项 +`resolveSessionsRoot()` 按与 `resolveDshHome()` 相同的优先级,解析 Harness 主目录下的共享会话存储根目录。`SESSIONS_DIR_NAME` 定义其目录名:`sessions`。每个持久化会话的界面都解析这同一个目录,因此历史记录在各工作目录之间共享,而不是按项目分散;持久化后端仍可在其内部分区。若两个界面解析出不同的根目录,会静默地把同一用户的历史记录拆成互不相交的存储,这正是该位置由此处拥有、而非由各调用方自行拼接的原因。 + +该包刻意保持规模小且不依赖 harness,以便产品包共享用户数据路径约定,而不必彼此依赖。 + +## 已知限制与待完成工作 - **展开范围刻意保持狭窄**:只有单独的 `~`、`~/...` 和 `~\...` 使用当前操作系统主目录;`~alice/...` 等指定用户的形式、环境变量和 shell 表达式保持不变。 - **辅助工具不会操作文件系统**:调用方仍负责目录创建、存在性检查、权限,以及对结果路径应用信任策略。 diff --git a/packages/util/paths/src/index.ts b/packages/util/paths/src/index.ts index c54a5e0a5f..85a6c881cd 100644 --- a/packages/util/paths/src/index.ts +++ b/packages/util/paths/src/index.ts @@ -52,6 +52,27 @@ export function resolveDshHome(configured?: string, env: Record = process.env, +): string { + return join(resolveDshHome(configuredHome, env), SESSIONS_DIR_NAME) +} + /** * Describe a resolved harness home symbolically for user-facing display. * diff --git a/packages/util/paths/tests/paths.spec.ts b/packages/util/paths/tests/paths.spec.ts index 6e1b94b1e9..5d41b52c44 100644 --- a/packages/util/paths/tests/paths.spec.ts +++ b/packages/util/paths/tests/paths.spec.ts @@ -4,10 +4,12 @@ import { describe, expect, it } from 'vitest' import { DEFAULT_DSH_HOME_DISPLAY, DSH_HOME_DIR_NAME, + SESSIONS_DIR_NAME, defaultDshHome, dshHomeDisplay, expandHomePath, resolveDshHome, + resolveSessionsRoot, } from '@deepseek-ai/dsh-paths' describe('dsh path helpers', () => { @@ -38,6 +40,15 @@ describe('dsh path helpers', () => { expect(resolveDshHome(undefined, { DSH_HOME: ' ' })).toBe(defaultDshHome()) }) + it('resolves the session store under the home it was given, by the same precedence', () => { + expect(SESSIONS_DIR_NAME).toBe('sessions') + expect(resolveSessionsRoot('/tmp/explicit-dsh', { DSH_HOME: '~/env-dsh' })) + .toBe(join(resolve('/tmp/explicit-dsh'), 'sessions')) + expect(resolveSessionsRoot(undefined, { DSH_HOME: '~/env-dsh' })) + .toBe(join(homedir(), 'env-dsh', 'sessions')) + expect(resolveSessionsRoot(undefined, {})).toBe(join(defaultDshHome(), 'sessions')) + }) + it('labels a resolved home by whether it is the default root', () => { expect(dshHomeDisplay(resolve(defaultDshHome()))).toBe('~/.dsh') expect(dshHomeDisplay('/some/other/root')).toBe('$DSH_HOME') From e7c0a5b7947232fd8eaedab013e9f2d5d3de846d Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 14:29:32 +0800 Subject: [PATCH 02/77] Merge origin/master: web permission sandbox, default pi-ai providers --- ...8-launcher-owned-resume-identity.i18n.yaml | 6 + ...26-07-28-launcher-owned-resume-identity.md | 62 ++ ...07-28-launcher-owned-resume-identity.zh.md | 62 ++ ...cated-full-screen-tui-front-door.i18n.yaml | 4 +- ...17-dedicated-full-screen-tui-front-door.md | 2 +- ...dedicated-full-screen-tui-front-door.zh.md | 2 +- .../2026-07-21-tui-resume-command.i18n.yaml | 6 +- .../feature/2026-07-21-tui-resume-command.md | 12 +- .../2026-07-21-tui-resume-command.zh.md | 12 +- ...24-configurable-tui-prompt-theme.i18n.yaml | 6 +- ...026-07-24-configurable-tui-prompt-theme.md | 2 +- ...-07-24-configurable-tui-prompt-theme.zh.md | 2 +- ...2026-07-27-tmux-location-context.i18n.yaml | 6 + .../2026-07-27-tmux-location-context.md | 61 ++ .../2026-07-27-tmux-location-context.zh.md | 61 ++ .../2026-07-27-tui-tool-card-header.i18n.yaml | 4 +- .../2026-07-27-tui-tool-card-header.md | 2 +- .../2026-07-27-tui-tool-card-header.zh.md | 2 +- ...026-07-28-cross-workspace-resume.i18n.yaml | 6 + .../2026-07-28-cross-workspace-resume.md | 52 ++ .../2026-07-28-cross-workspace-resume.zh.md | 52 ++ ...sh-guided-skill-session-commands.i18n.yaml | 6 + ...07-28-dsh-guided-skill-session-commands.md | 43 ++ ...28-dsh-guided-skill-session-commands.zh.md | 43 ++ ...-07-28-dsh-meta-source-workspace.i18n.yaml | 6 + .../2026-07-28-dsh-meta-source-workspace.md | 49 ++ ...2026-07-28-dsh-meta-source-workspace.zh.md | 49 ++ ...live-session-registry-and-dsh-ls.i18n.yaml | 6 + ...-07-28-live-session-registry-and-dsh-ls.md | 65 ++ ...-28-live-session-registry-and-dsh-ls.zh.md | 65 ++ ...8-source-guard-staging-edit-gate.i18n.yaml | 6 + ...26-07-28-source-guard-staging-edit-gate.md | 76 +++ ...07-28-source-guard-staging-edit-gate.zh.md | 76 +++ apps/cli/README.md | 14 +- apps/cli/README.zh.md | 14 +- apps/cli/package.json | 3 + apps/cli/src/app-cli-entry.ts | 11 +- apps/cli/src/args.ts | 149 ++++- apps/cli/src/bin.ts | 16 + apps/cli/src/headless.ts | 2 + apps/cli/src/list-sessions.ts | 101 +++ apps/cli/src/register-session.ts | 44 ++ apps/cli/src/tui.ts | 144 ++++- apps/cli/src/web.ts | 2 + apps/cli/tests/args.spec.ts | 39 +- apps/cli/tests/built-bin.e2e.ts | 55 +- apps/cli/tests/list-sessions.spec.ts | 74 +++ apps/cli/tests/sessions-root.spec.ts | 20 + apps/cli/tsconfig.json | 9 + docs/capability-seams.md | 8 + docs/config-catalog.md | 86 ++- docs/cordis-catalog/services.md | 38 ++ docs/i18n/terminology.md | 1 + docs/module-graph.md | 33 + examples/cordis-agent/cordis.yml | 1 - .../fixtures/guard/source-guard/cordis.yml | 38 ++ .../fixtures/guard/source-guard/mock-llm.ts | 43 ++ .../guard/source-guard/mount-guard.ts | 14 + .../tests/fixtures/tmux-context-driver.ts | 16 + .../tests/fixtures/tmux-context-mock-bash.ts | 46 ++ .../tests/fixtures/tmux-context-mock-llm.ts | 22 + .../tests/fixtures/tmux-context.cordis.yml | 21 + examples/package.json | 3 + examples/tui-agent/README.md | 2 +- examples/tui-agent/code-mode.cordis.yml | 2 - examples/tui-agent/composition.md | 3 + examples/tui-agent/cordis.yml | 23 +- .../tests/fixtures/tui-scripted-llm.ts | 27 +- .../tests/fixtures/tui-scripted.cordis.yml | 2 - .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 74 ++- knip.json | 35 ++ packages/context/README.md | 5 +- .../context/tmux-context/README.i18n.yaml | 6 + packages/context/tmux-context/README.md | 68 ++ packages/context/tmux-context/README.zh.md | 68 ++ packages/context/tmux-context/package.json | 49 ++ packages/context/tmux-context/src/index.ts | 227 +++++++ .../context/tmux-context/src/invariant.ts | 30 + .../tmux-context/tests/tmux-context.e2e.ts | 77 +++ .../tmux-context/tests/tmux-context.spec.ts | 367 +++++++++++ packages/context/tmux-context/tsconfig.json | 40 ++ .../cordis/tool-cordis/src/api-catalog.ts | 30 + packages/examples/tui-demo/README.md | 8 +- packages/examples/tui-demo/package.json | 16 +- packages/examples/tui-demo/src/index.ts | 47 +- .../examples/tui-demo/tests/tui-agent.spec.ts | 67 +- packages/examples/tui-demo/tsconfig.json | 3 + packages/guard/README.md | 5 +- packages/guard/source-guard/README.i18n.yaml | 6 + packages/guard/source-guard/README.md | 88 +++ packages/guard/source-guard/README.zh.md | 88 +++ packages/guard/source-guard/package.json | 56 ++ packages/guard/source-guard/src/index.ts | 319 ++++++++++ packages/guard/source-guard/src/invariant.ts | 85 +++ .../source-guard/tests/invariant.spec.ts | 134 ++++ .../tests/loader-composition.e2e.ts | 93 +++ .../source-guard/tests/source-guard.spec.ts | 581 ++++++++++++++++++ packages/guard/source-guard/tsconfig.json | 42 ++ packages/session-registry/README.i18n.yaml | 6 + packages/session-registry/README.md | 15 + packages/session-registry/README.zh.md | 15 + .../session-registry-file/README.i18n.yaml | 6 + .../session-registry-file/README.md | 42 ++ .../session-registry-file/README.zh.md | 42 ++ .../session-registry-file/package.json | 47 ++ .../session-registry-file/src/file.ts | 97 +++ .../session-registry-file/src/index.ts | 233 +++++++ .../session-registry-file/src/invariant.ts | 32 + .../session-registry-file/src/liveness.ts | 30 + .../tests/fixtures/register-once.ts | 27 + .../tests/session-registry-file.spec.ts | 382 ++++++++++++ .../session-registry-file/tsconfig.json | 21 + .../session-registry-live/README.i18n.yaml | 6 + .../session-registry-live/README.md | 32 + .../session-registry-live/README.zh.md | 32 + .../session-registry-live/package.json | 44 ++ .../session-registry-live/src/index.ts | 84 +++ .../session-registry-live/src/invariant.ts | 31 + .../tests/session-registry-live.spec.ts | 227 +++++++ .../session-registry-live/tsconfig.json | 24 + .../session-registry/README.i18n.yaml | 6 + .../session-registry/README.md | 30 + .../session-registry/README.zh.md | 30 + .../session-registry/package.json | 41 ++ .../session-registry/src/index.ts | 82 +++ .../session-registry/src/invariant.ts | 58 ++ .../session-registry/src/types.ts | 55 ++ .../session-registry/tests/invariant.spec.ts | 98 +++ .../session-registry/tsconfig.json | 21 + .../support/acp-snapshot/README.i18n.yaml | 4 +- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/README.zh.md | 2 +- packages/support/acp-snapshot/src/harness.ts | 8 + packages/support/acp-snapshot/src/suite.ts | 7 + .../suite/plain-turn/stdout.expected.jsonl | 2 +- .../support/acp-snapshot/tests/suite.spec.ts | 3 +- packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 3 +- packages/ui/app-boot/README.zh.md | 3 +- packages/ui/app-boot/src/index.ts | 11 - pnpm-lock.yaml | 188 ++++++ scripts/gen-cordis-catalog.ts | 2 + scripts/gen-doc-graphs.ts | 9 + .../verify-package-readme-model-experience.ts | 3 + skills/dsh-migrate/SKILL.md | 57 ++ tsconfig.base.json | 2 + tsconfig.host.json | 5 + 147 files changed, 6770 insertions(+), 195 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md create mode 100644 .agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-27-tmux-location-context.md create mode 100644 .agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md create mode 100644 .agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md create mode 100644 .agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md create mode 100644 .agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md create mode 100644 .agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md create mode 100644 .agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.zh.md create mode 100644 apps/cli/src/list-sessions.ts create mode 100644 apps/cli/src/register-session.ts create mode 100644 apps/cli/tests/list-sessions.spec.ts create mode 100644 apps/cli/tests/sessions-root.spec.ts create mode 100644 examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml create mode 100644 examples/headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts create mode 100644 examples/headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts create mode 100644 examples/headless-agent/tests/fixtures/tmux-context-driver.ts create mode 100644 examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts create mode 100644 examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts create mode 100644 examples/headless-agent/tests/fixtures/tmux-context.cordis.yml create mode 100644 packages/context/tmux-context/README.i18n.yaml create mode 100644 packages/context/tmux-context/README.md create mode 100644 packages/context/tmux-context/README.zh.md create mode 100644 packages/context/tmux-context/package.json create mode 100644 packages/context/tmux-context/src/index.ts create mode 100644 packages/context/tmux-context/src/invariant.ts create mode 100644 packages/context/tmux-context/tests/tmux-context.e2e.ts create mode 100644 packages/context/tmux-context/tests/tmux-context.spec.ts create mode 100644 packages/context/tmux-context/tsconfig.json create mode 100644 packages/guard/source-guard/README.i18n.yaml create mode 100644 packages/guard/source-guard/README.md create mode 100644 packages/guard/source-guard/README.zh.md create mode 100644 packages/guard/source-guard/package.json create mode 100644 packages/guard/source-guard/src/index.ts create mode 100644 packages/guard/source-guard/src/invariant.ts create mode 100644 packages/guard/source-guard/tests/invariant.spec.ts create mode 100644 packages/guard/source-guard/tests/loader-composition.e2e.ts create mode 100644 packages/guard/source-guard/tests/source-guard.spec.ts create mode 100644 packages/guard/source-guard/tsconfig.json create mode 100644 packages/session-registry/README.i18n.yaml create mode 100644 packages/session-registry/README.md create mode 100644 packages/session-registry/README.zh.md create mode 100644 packages/session-registry/session-registry-file/README.i18n.yaml create mode 100644 packages/session-registry/session-registry-file/README.md create mode 100644 packages/session-registry/session-registry-file/README.zh.md create mode 100644 packages/session-registry/session-registry-file/package.json create mode 100644 packages/session-registry/session-registry-file/src/file.ts create mode 100644 packages/session-registry/session-registry-file/src/index.ts create mode 100644 packages/session-registry/session-registry-file/src/invariant.ts create mode 100644 packages/session-registry/session-registry-file/src/liveness.ts create mode 100644 packages/session-registry/session-registry-file/tests/fixtures/register-once.ts create mode 100644 packages/session-registry/session-registry-file/tests/session-registry-file.spec.ts create mode 100644 packages/session-registry/session-registry-file/tsconfig.json create mode 100644 packages/session-registry/session-registry-live/README.i18n.yaml create mode 100644 packages/session-registry/session-registry-live/README.md create mode 100644 packages/session-registry/session-registry-live/README.zh.md create mode 100644 packages/session-registry/session-registry-live/package.json create mode 100644 packages/session-registry/session-registry-live/src/index.ts create mode 100644 packages/session-registry/session-registry-live/src/invariant.ts create mode 100644 packages/session-registry/session-registry-live/tests/session-registry-live.spec.ts create mode 100644 packages/session-registry/session-registry-live/tsconfig.json create mode 100644 packages/session-registry/session-registry/README.i18n.yaml create mode 100644 packages/session-registry/session-registry/README.md create mode 100644 packages/session-registry/session-registry/README.zh.md create mode 100644 packages/session-registry/session-registry/package.json create mode 100644 packages/session-registry/session-registry/src/index.ts create mode 100644 packages/session-registry/session-registry/src/invariant.ts create mode 100644 packages/session-registry/session-registry/src/types.ts create mode 100644 packages/session-registry/session-registry/tests/invariant.spec.ts create mode 100644 packages/session-registry/session-registry/tsconfig.json create mode 100644 skills/dsh-migrate/SKILL.md diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml new file mode 100644 index 0000000000..b63d7d3093 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md +2026-07-28-launcher-owned-resume-identity.md: 8c48194892f67c1a0f3f87094cd174ca1a71a383 +2026-07-28-launcher-owned-resume-identity.zh.md: 88113017986ac8aaf473d246f701c977804353bf diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md new file mode 100644 index 0000000000..8c48194892 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md @@ -0,0 +1,62 @@ +# Agent Note: Launcher-owned session identity and exit line + +Status: implemented + +English | [中文](2026-07-28-launcher-owned-resume-identity.zh.md) + +## Problem + +Two facts a launcher owns were shipped as deployment config keys on `dsh-tui-demo`: `resumeSessionId` (which session `main` binds to) and `resumeCommand` (the exit hint template, with `{session}` interpolated). Neither varies by deployment — both are properties of how the process was invoked, which only the launcher knows. + +Routing them through YAML made them silently droppable. `@cordisjs/plugin-include` applies a targeted patch by replacing whole top-level keys (`target[key] = value`), so a personal `~/.dsh/config.yaml` patching the `tui-agent` entry's `config` replaces the shipped block entirely. A user overlay written to change provider and model therefore deleted every resume key it did not restate, and nothing reported it: absent `resumeCommand` legitimately means "no fallback configured". + +Both failures were live in one real overlay. The exit hint stopped printing, because the overlay omitted `resumeCommand`. Worse, the overlay carried `resumeSessionId: !!js process.env.RESUME_SESSION_ID` — a stale line from before [the env-var bridge was removed](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md) — which overwrote the shipped `!!js "typeof resumeSessionId === 'string' ? …"` intake with a read of a variable nothing sets. `dsh --resume ` then started a *fresh* session and said nothing, reproduced directly: the banner showed a newly minted id, not the requested one. The [`dsh meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note had recorded this silent resume as an unexplained pre-existing defect; the overlay's shallow replacement is the cause. + +A config key cannot express these facts safely, because the deployment is not the authority on them. + +## Decision + +Session identity and the exit line are launcher-owned context slots, provided before any Loader entry mounts. Neither appears in any `cordis.yml` or in `dsh-tui`'s or `dsh-tui-demo`'s `Config`. + +`dsh-tui` declares both slots beside the existing `tuiResumeHost` host capability, which set the precedent — a resume host has always been a provided capability rather than config: + +- `MAIN_SESSION_ID_KEY` carries a `MainSessionIdentity` (`{ id: SessionId, resume: boolean }`). `dsh-tui-demo` binds both the TUI and the configured agent to `id`, and takes the history-loading `resumeSessionId` path only when `resume` is set, because that path requires an existing log and fails loud without one. An absent slot means no launcher chose a session, so the app mints `main-session-` and creates it fresh. +- `TUI_GOODBYE_MESSAGE_KEY` carries the complete line printed once the terminal is released on exit. Absent prints nothing. + +`apps/cli` mints or selects the id and builds the line from the invocation it is reproducing, sharing one `resumeArgs` helper with the `/resume` execve handoff so the printed command and the in-place handoff cannot diverge. The line now names `--config` when one was passed, and reproduces `dsh meta --resume ` in meta mode — closing the mode-aware hint deferred by the `dsh meta` note, where a copied hint previously only worked from the checkout. + +**`ctx.provide` is the only channel from launcher argv into a Loader-mounted plugin.** Config `!!js` expressions evaluate as `with (entry.ctx) { eval(expr) }` (`vendor/loader/src/config/utils.ts`), so a bare identifier resolves against the entry's context and nothing else reaches it. The slot therefore cannot be removed while the app bundle is mounted from YAML; what changes is that it is now internal launcher↔app plumbing instead of a documented key a config author must wire correctly. + +The message is a plain string, not a callback. That forces the launcher to know the id before boot, which is why minting moved out of the app bundle — and it keeps exit free of awaited work after the terminal is released. + +The TUI owns rendering, not wording: it applies `displayText` before its own `palette.muted`, so a hostile `--config` path cannot inject terminal escapes into the exit line. Sanitizing means the launcher cannot embed its own ANSI. + +## Alternatives considered + +**Keep the keys and add built-in defaults in `dsh-tui-demo`.** Rejected: a default in code survives an overlay, but two ways to state one fact remain, and a config author can still set the key wrong — which is exactly how the stale `process.env.RESUME_SESSION_ID` line disabled resume. + +**Merge `dsh-tui-demo` into `apps/cli` and delete the slot entirely.** Rejected after investigation, though it is the only way to remove the slot. `examples/tui-agent/code-mode.cordis.yml` patches the `tui-agent` entry through a nested `plugin-include` to switch `tools.mode` and the persona, and `examples/cordis-agent/cordis.yml` reuses the bundle as a different product; both extension points exist only because `tui-agent` is a declared config entry. Merging also moves a 162-line, 18-dependency composition into the CLI's `v8 ignore` process-wiring block, out of the per-file coverage gate. + +**Put the goodbye message on `TuiResumeHost`.** Rejected: an exit line is not a handoff capability, and a host that cannot replace its process may still want to print one. They are independent slots. + +**Have the host supply only the command text and let the TUI keep the `To resume this session:` prefix.** Rejected: the TUI would retain resume vocabulary for a string it no longer understands, and meta mode proves the launcher is the only component that knows what the command should say. + +**Let the TUI keep suppressing the line until the session is durably persisted.** Rejected: that check is why the exit path queried persistence and swallowed listing failures. A plain string cannot consult persistence, and misuse now fails loud through `agent-loop/config-start-failed` rather than silently resuming nothing. + +**A callback (`goodbyeMessage(agent)`) so the host could decide at exit time.** Rejected: it restores async work after `ui.stop()`, reintroducing a hang risk during teardown for a string that is already knowable at boot. + +## Consequences + +- Removing two published `Config` keys is a breaking config change: a stale config naming either now fails schema validation at boot instead of degrading silently. Intended, and acceptable pre-release. +- `TuiResumeHost` is unchanged, but `TuiRuntime` gains `goodbyeMessage`; `apps/cli` is the only provider. +- The exit line prints even for a session with no log (launch, quit immediately). Using it then fails loud rather than starting a surprise session. This is the deliberate cost of dropping the persistence check. +- `dsh-tui` no longer reads `sessionPersistence` at all: `currentResumeCommand`, `listWorkspaceSessions`, and its swallowed-error path are deleted, and the `/resume` selector's `sessionQuery` reads are now the only session discovery in the TUI. +- The launcher mints session ids for its own app, so a non-CLI host that provides no slot keeps the bundle's own minting. + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` pins the printed line, the absent-slot silence, and escape sanitization of a hostile message; the former two exit-suppression tests are replaced, since suppression is the behavior this change removes. `packages/examples/tui-demo/tests/tui-agent.spec.ts` drives the identity slot for the resume, launcher-minted, and no-slot cases through a fake `ctx.get`. + +The load-bearing coverage is `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts`, which launches the real `apps/cli/src/bin.ts` in a PTY: one test asserts the exit line carries `--config`, and a regression test seeds a personal `config.yaml` that replaces the entire `tui-agent` config block and asserts the line still prints — encoding "an overlay cannot drop resume" as an executed contract rather than a comment. + +Verified live in tmux against the real personal overlay: the defect reproduced on unmodified staging (requested id ignored, fresh id in the banner), and on this branch the same overlay yields a printed exit line, a `--resume` that restores the prior turn, and a `/resume` selector marking the session `current · live · persisted`. A wrong id now fails loud. diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md new file mode 100644 index 0000000000..8811301798 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md @@ -0,0 +1,62 @@ +# Agent Note:由启动器持有的会话身份与退出行 + +Status: implemented + +[English](2026-07-28-launcher-owned-resume-identity.md) | 中文 + +## Problem + +有两项本应由启动器持有的事实,却被作为 `dsh-tui-demo` 上的部署配置键交付:`resumeSessionId`(`main` 绑定到哪个会话)与 `resumeCommand`(退出提示的模板,其中 `{session}` 会被插值)。二者都不随部署而变——它们都是进程被如何调用的属性,而这一点只有启动器知道。 + +把它们经由 YAML 传递,使其可被静默丢弃。`@cordisjs/plugin-include` 施加定向补丁的方式是替换整个顶层键(`target[key] = value`),因此一份对 `tui-agent` 条目的 `config` 打补丁的个人 `~/.dsh/config.yaml`,会把交付时的整块内容整体替换掉。于是,一份为改动 provider 和 model 而写的用户 overlay,会删掉它未重述的每一个 resume 键,且没有任何东西报告这一点:缺失 `resumeCommand` 合法地意味着「未配置回退」。 + +两处失效在同一份真实的 overlay 中同时存在。退出提示不再打印,因为该 overlay 省略了 `resumeCommand`。更糟的是,该 overlay 带着 `resumeSessionId: !!js process.env.RESUME_SESSION_ID`——一行来自 [env 变量桥被移除](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md)之前的陈旧代码——它用一次对某个无人设置的变量的读取,覆盖掉了交付时的 `!!js "typeof resumeSessionId === 'string' ? …"` 入口。此后 `dsh --resume ` 会开启一个*全新*会话且什么都不说,并被直接复现:banner 显示的是一个新铸造的 id,而非所请求的那个。[`dsh meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note 曾把这次静默的 resume 记为一处无法解释的既有缺陷;而 overlay 的浅层替换正是其成因。 + +一个配置键无法安全地表达这些事实,因为部署方并非它们的权威。 + +## Decision + +会话身份与退出行是由启动器持有的上下文槽位,在任何 Loader 条目挂载之前提供。二者都不出现在任何 `cordis.yml` 中,也不出现在 `dsh-tui` 或 `dsh-tui-demo` 的 `Config` 中。 + +`dsh-tui` 在既有的 `tuiResumeHost` 宿主能力旁声明这两个槽位,后者确立了先例——resume 宿主一直是一项被提供的能力,而非配置: + +- `MAIN_SESSION_ID_KEY` 承载一个 `MainSessionIdentity`(`{ id: SessionId, resume: boolean }`)。`dsh-tui-demo` 把 TUI 与所配置的 agent 都绑定到 `id`,并且仅当 `resume` 被置位时才走加载历史的 `resumeSessionId` 路径,因为该路径要求存在一份日志、否则会明确报错。槽位缺失意味着没有启动器选定会话,于是应用铸造 `main-session-` 并新建它。 +- `TUI_GOODBYE_MESSAGE_KEY` 承载退出时终端释放后打印一次的完整行。缺失则什么都不打印。 + +`apps/cli` 铸造或选定 id,并依据它所复现的那次调用构建该行,与 `/resume` 的 execve 移交共用同一个 `resumeArgs` 助手,从而使打印出的命令与原地移交不会分歧。该行现在会在传入了 `--config` 时命名它,并在 meta 模式下复现 `dsh meta --resume `——从而收口了 `dsh meta` note 所推迟的随 mode 变化的提示,在那里被复制的提示此前只有在检出目录中才有效。 + +**`ctx.provide` 是从启动器 argv 进入被 Loader 挂载的插件的唯一通道。** 配置的 `!!js` 表达式会以 `with (entry.ctx) { eval(expr) }`(`vendor/loader/src/config/utils.ts`)求值,因此一个裸标识符会针对该条目的上下文解析,别无它物可达。于是只要应用 bundle 仍从 YAML 挂载,这个槽位就无法被移除;变化之处在于它现在是启动器↔应用之间的内部管线,而不再是一个配置作者必须正确接线的、有文档记载的键。 + +该消息是一个纯字符串,而非回调。这迫使启动器在启动前就知道 id,也正是铸造从应用 bundle 中移出的原因——并且它让退出在终端释放之后免于任何被 await 的工作。 + +TUI 持有渲染,而非措辞:它在自己的 `palette.muted` 之前先应用 `displayText`,因此一个恶意的 `--config` 路径无法把终端转义序列注入退出行。做净化意味着启动器无法嵌入自己的 ANSI。 + +## Alternatives considered + +**保留这些键,并在 `dsh-tui-demo` 中加入内建默认值。** 拒绝:代码中的默认值能在 overlay 下存活,但表达同一事实的两种途径依然并存,而配置作者仍可把键设错——这正是那行陈旧的 `process.env.RESUME_SESSION_ID` 使 resume 失效的方式。 + +**把 `dsh-tui-demo` 合并进 `apps/cli` 并彻底删除该槽位。** 经调查后拒绝,尽管这是移除该槽位的唯一途径。`examples/tui-agent/code-mode.cordis.yml` 通过一个嵌套的 `plugin-include` 给 `tui-agent` 条目打补丁,以切换 `tools.mode` 与人设,而 `examples/cordis-agent/cordis.yml` 把该 bundle 作为另一款产品复用;这两个扩展点都仅因 `tui-agent` 是一个声明式配置条目才存在。合并还会把一段 162 行、18 个依赖的组合逻辑挪进 CLI 的 `v8 ignore` 进程接线块中,脱离逐文件覆盖率门禁。 + +**把 goodbye 消息放到 `TuiResumeHost` 上。** 拒绝:退出行不是一项移交能力,而一个无法替换自身进程的宿主仍可能想要打印一行。它们是相互独立的槽位。 + +**让宿主只提供命令文本,而由 TUI 保留 `To resume this session:` 前缀。** 拒绝:TUI 将为一个它已不再理解的字符串保留 resume 词汇,而 meta 模式证明启动器才是唯一知道该命令应当说什么的组件。 + +**让 TUI 继续在会话被持久化之前抑制该行。** 拒绝:这项检查正是退出路径要查询持久化并吞掉列举失败的原因。一个纯字符串无法查询持久化,而误用现在会经由 `agent-loop/config-start-failed` 明确报错,而不是静默地恢复了个空。 + +**用一个回调(`goodbyeMessage(agent)`)让宿主能在退出时决定。** 拒绝:它会在 `ui.stop()` 之后恢复异步工作,为一个在启动时就已可知的字符串,重新引入拆解期间的挂起风险。 + +## Consequences + +- 移除两个已发布的 `Config` 键是一次破坏性配置变更:一份命名了任一键的陈旧配置,现在会在启动时的 schema 校验中明确报错,而不再静默降级。这是有意为之,且在预发布阶段可以接受。 +- `TuiResumeHost` 保持不变,但 `TuiRuntime` 新增 `goodbyeMessage`;`apps/cli` 是唯一的提供方。 +- 即便某会话没有日志(启动后立即退出),退出行也会打印。此时使用它会明确报错,而不是开启一个意外的会话。这是丢弃持久化检查的有意代价。 +- `dsh-tui` 完全不再读取 `sessionPersistence`:`currentResumeCommand`、`listWorkspaceSessions` 及其吞错路径都被删除,`/resume` 选择器的 `sessionQuery` 读取如今是 TUI 中唯一的会话发现途径。 +- 启动器为其自身的应用铸造会话 id,因此一个不提供任何槽位的非 CLI 宿主,仍保留 bundle 自带的铸造逻辑。 + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` 钉住打印出的行、槽位缺失时的静默,以及对恶意消息的转义净化;此前那两个退出抑制测试被替换,因为抑制正是本次改动移除的行为。`packages/examples/tui-demo/tests/tui-agent.spec.ts` 通过一个伪造的 `ctx.get`,为 resume、启动器铸造与无槽位三种情形驱动身份槽位。 + +承重的覆盖是 `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts`,它在一个 PTY 中拉起真实的 `apps/cli/src/bin.ts`:一个测试断言退出行携带 `--config`,一个回归测试植入一份个人 `config.yaml` 来替换整块 `tui-agent` 配置块并断言该行仍会打印——把「overlay 不能丢掉 resume」编码为一条被执行的契约,而非一句注释。 + +在 tmux 中针对真实的个人 overlay 做过实测:该缺陷在未修改的 staging 上复现(所请求的 id 被忽略,banner 里是新的 id),而在本分支上同一份 overlay 会产出一行打印的退出行、一个能恢复上一轮次的 `--resume`,以及一个把该会话标记为 `current · live · persisted` 的 `/resume` 选择器。错误的 id 现在会明确报错。 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml index 3df1059e1f..bba253325c 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md -2026-07-17-dedicated-full-screen-tui-front-door.md: a3f3d6b51e85ad20218ce5aebf526bd96946be55 -2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 6a0c2f12815e9418a2b5bcb237d3db3616ccd133 +2026-07-17-dedicated-full-screen-tui-front-door.md: bc6241e925d5bf094deded761fb96fd6b6c48a1f +2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 0f5306ff547cb975b29d1fbcb7e610b3a540ce18 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md index a3f3d6b51e..bc6241e925 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md @@ -24,7 +24,7 @@ The TUI rebuilds the transcript from the active `session.surface` and reprojects Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. `/exit` and `/quit` share the same exit path: they cancel an active turn, wait for idle, and then restore and close the terminal. The idle footer derives context occupancy from `tokenMeter` and shows the selected model and explicit reasoning effort; during a run, elapsed activity and the Escape interrupt hint replace that summary. `/status` remains available in either state and appends a detailed terminal-only snapshot: session identity and timestamps, selected model, reasoning effort/default state and reasoning visibility, lifecycle counts folded from the event log, the same deduplicated usage buckets and KV-cache rate as the footer, and context use from `tokenMeter` plus the selected model's advertised capacity. The plugin registers the shared `userInteraction` provider and presents queued questions in a wide bottom-left keyboard panel with batch progress, numbered options, and aligned descriptions; the panel's controls hint lists only actions meaningful for the current option count, omitting navigation when exactly one option is shown; agent behavior and answer logging remain owned by their existing services. -The `/model` command presents the advisory `ctx.llm` catalog as a keyboard selector and changes only this TUI session's target; argument forms remain available for direct selection. Each model row owns the adapter-advertised reasoning-effort order and default: Shift+Tab cycles that row's efforts, includes provider-default behavior when the adapter advertises no default, and leaves models without selectable metadata unchanged. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model/reasoning-effort target per step, so `{{provider}}` / `{{model}}` interpolation and request routing cannot split when a command arrives during assembly. The latest logged request header restores a used target; a selection that never reaches a request remains process-local. +The `/model` command presents the advisory `ctx.llm` catalog as a keyboard selector and changes only this TUI session's target; argument forms remain available for direct selection. The selector carries a filter box above the list: typing narrows the rows to a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the selection on the previously highlighted row when it survives the filter; Escape clears a non-empty filter before a second Escape cancels the selector. Each model row owns the adapter-advertised reasoning-effort order and default: Shift+Tab cycles that row's efforts, includes provider-default behavior when the adapter advertises no default, and leaves models without selectable metadata unchanged. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model/reasoning-effort target per step, so `{{provider}}` / `{{model}}` interpolation and request routing cannot split when a command arrives during assembly. The latest logged request header restores a used target; a selection that never reaches a request remains process-local. ### Terminal ownership diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md index 6a0c2f1281..0f5306ff54 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md @@ -24,7 +24,7 @@ TUI 从活跃的 `session.surface` 重建 transcript(文本记录),并在 agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。`/exit` 和 `/quit` 共用同一条退出路径:先取消进行中的轮次,等待 agent 空闲,然后恢复并关闭终端。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并显示所选模型和显式选定的推理强度;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。`/status` 在这两种状态下均可用,并会追加一份仅在终端显示的详细快照,其中包括会话标识与时间戳、所选模型、推理强度(或默认状态)及推理显隐状态、从事件日志归并得出的生命周期计数、与页脚一致的去重用量分项和 KV 缓存命中率,以及 `tokenMeter` 给出的上下文用量和所选模型公布的容量。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;面板的操作提示只列出在当前选项数量下有意义的操作,仅有一个选项时不显示导航项;agent 行为和答案日志仍由既有服务负责。 -`/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标;带参数的形式仍可直接选择目标。每个模型行都持有适配器公布的推理强度顺序和默认值:按 Shift+Tab 可循环切换该行的推理强度;如果适配器没有公布默认值,循环中还会包含提供方默认行为;没有可选元数据的模型则保持不变。agent 作用域内的 prompt 组装和请求两条 waterfall(瀑布式事件)会为每个步骤快照一次同一个提供方/模型/推理强度目标,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。 +`/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标;带参数的形式仍可直接选择目标。选择器在列表上方设有一个过滤框:输入内容会按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在原先高亮行仍通过过滤时保持其选中状态;Escape 会先清空非空的过滤内容,再次按 Escape 才取消选择器。每个模型行都持有适配器公布的推理强度顺序和默认值:按 Shift+Tab 可循环切换该行的推理强度;如果适配器没有公布默认值,循环中还会包含提供方默认行为;没有可选元数据的模型则保持不变。agent 作用域内的 prompt 组装和请求两条 waterfall(瀑布式事件)会为每个步骤快照一次同一个提供方/模型/推理强度目标,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。 ### 终端所有权 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml index d470b61414..ea5ef87849 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-21-tui-resume-command.md: 86f62e16f5e2ee83e2ed36f0ed675ca2a1422c4b -2026-07-21-tui-resume-command.zh.md: 06e58f81445aaaf5299282714148194c1d2aacf4 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-tui-resume-command.md +2026-07-21-tui-resume-command.md: c8cb855378d793a168e1f87d6b41f9a48db7dc14 +2026-07-21-tui-resume-command.zh.md: 2a7e74d1106499cb7d7232dc13954ae126246550 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md index 86f62e16f5..c8cb855378 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md @@ -10,17 +10,17 @@ The original `/resume` printed shell commands. It did not let a keyboard user in ## Decision -`/resume` uses the TUI's existing interactive overlay seam as a full-viewport picker rather than a centered dialog. The flat page keeps the search field, workspace, candidates, and shortcut footer in stable screen regions; only the active row uses the accent role. Its search editor starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored in the field. Escape clears a non-empty query before a second Escape closes the picker. It lists the current workspace by last logged activity and searches log-backed title or id. Each candidate displays current/live/persisted state, last turn outcome, recent provider/model, durable goal phase when present, and the id as secondary text. The current session and sessions already live in this runtime remain visible but disabled. +`/resume` uses the TUI's existing interactive overlay seam as a full-viewport picker rather than a centered dialog. The flat page keeps the search field, workspace scope line, candidates, and shortcut footer in stable screen regions; only the active row uses the accent role. Its search editor starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored in the field. Escape clears a non-empty query before a second Escape closes the picker. It orders candidates by last logged activity and searches log-backed title or id. Each candidate displays current/live/persisted state, last turn outcome, recent provider/model, durable goal phase when present, and the id as secondary text. The current session and sessions already live in this runtime remain visible but disabled. The picker opens on the current workspace and reaches every other one through the scope toggle the [cross-workspace resume](2026-07-28-cross-workspace-resume.md) note owns. -`session-query.readSession()` supplies a detached complete log validated by the same core replay boundary used by resume. The TUI folds title and goal state from that log. A candidate load failure is local to that row; selecting a candidate revalidates the log, `cwd`, route, current agent's idle status, and the exclusions for the current session and sessions already live in this runtime, so a stale listing cannot bypass preflight. A missing adapter reports an intact session with an unavailable route. This preflight does not lock the target or exclude another process. +`session-query.readSession()` supplies a detached complete log validated by the same core replay boundary used by resume. The TUI folds title and goal state from that log. A candidate load failure is local to that row; selecting a candidate revalidates the log, workspace, route, current agent's idle status, and the exclusions for the current session and sessions already live in this runtime, so a stale listing cannot bypass preflight. A missing adapter reports an intact session with an unavailable route. This preflight does not lock the target or exclude another process. -After preflight, the TUI flushes the current session, confirms that its agent remains idle, then stops the terminal before calling `TuiRuntime.handoffResume`. The shipped `dsh` host disposes the root app and uses `process.execve` with a normalized `--resume` argument, atomically replacing the process rather than starting a child. The resumed app publishes the same `SessionId`; ordinary replay restores transcript, title, todos, and durable goal state. Goal activation is intentionally disarmed, and the TUI asks for human confirmation or `/goal resume`. +After preflight, the TUI flushes the current session, confirms that its agent remains idle, then stops the terminal before calling `TuiRuntime.handoffResume` with the validated id and the target workspace. The shipped `dsh` host disposes the root app and uses `process.execve` with a normalized `--resume` argument, atomically replacing the process rather than starting a child. The resumed app publishes the same `SessionId`; ordinary replay restores transcript, title, todos, and durable goal state. Goal activation is intentionally disarmed, and the TUI asks for human confirmation or `/goal resume`. -`resumeCommand` remains an exit and no-host fallback. The TUI substitutes `{session}` only for display and never executes arbitrary shell text. The exit hint still appears only after the current session is durable. +The exit line is a launcher-owned context slot rather than a config template, and a host without in-place handoff reports that the session stays resumable instead of naming a command it cannot construct; the [launcher-owned resume identity](../architecture/2026-07-28-launcher-owned-resume-identity.md) note owns that ownership move and supersedes the `resumeCommand` config key this note originally shipped. The TUI still never executes shell text. ## Alternatives considered -**Have the TUI spawn `resumeCommand`.** Rejected: the template is deployment text, not trusted argv, and the TUI does not own app teardown or process lifetime. The constrained host seam receives only a validated `SessionId`. +**Have the TUI spawn the resume command.** Rejected: the text is display copy, not trusted argv, and the TUI does not own app teardown or process lifetime. The constrained host seam receives only a validated `SessionId`. **Construct the resumed agent inside the existing TUI.** Rejected: replacing one config-created agent would cross Loader ownership, scoped plugin setup, persistence retirement, and terminal lifecycle in the presentation layer. Root disposal plus process replacement reuses the supported startup path. @@ -36,4 +36,4 @@ After preflight, the TUI flushes the current session, confirms that its agent re ## Testing -TUI tests cover keyboard navigation, title/id search, search-clear/cancel behavior, running-agent refusal, refusal of the current session and sessions already live in this runtime, route absence, corrupt rows, preflight revalidation, fallback commands, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The keyless TUI snapshot owns the full-viewport selector and its IME cursor anchor, and a real PTY smoke covers search plus handoff. +TUI tests cover keyboard navigation, title/id search, search-clear/cancel behavior, running-agent refusal, refusal of the current session and sessions already live in this runtime, route absence, corrupt rows, preflight revalidation, the no-host warning, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The keyless TUI snapshot owns the full-viewport selector and its IME cursor anchor, and a real PTY smoke covers search plus handoff. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md index 06e58f8144..2a7e74d110 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md @@ -10,17 +10,17 @@ Status: implemented ## Decision -`/resume` 使用 TUI 现有的交互式浮层接口,但以占满 viewport 的选择页呈现,而不是居中弹窗。这个扁平页面把搜索框、workspace、候选项和快捷键页脚放在稳定的屏幕区域,只有当前行使用强调色。搜索编辑器紧跟搜索图标起始,并输出 pi-tui 的光标标记,因此终端输入法的组合文本会锚定在输入框中。查询非空时,第一次按 Escape 会清空查询,第二次才关闭选择页。页面按日志记录的最后活动时间列出当前 workspace 的会话,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和已在本运行时中处于活跃状态的会话仍会显示,但不可选择。 +`/resume` 使用 TUI 现有的交互式浮层接口,但以占满 viewport 的选择页呈现,而不是居中弹窗。这个扁平页面把搜索框、workspace 作用域行、候选项和快捷键页脚放在稳定的屏幕区域,只有当前行使用强调色。搜索编辑器紧跟搜索图标起始,并输出 pi-tui 的光标标记,因此终端输入法的组合文本会锚定在输入框中。查询非空时,第一次按 Escape 会清空查询,第二次才关闭选择页。页面按日志记录的最后活动时间排列候选项,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和已在本运行时中处于活跃状态的会话仍会显示,但不可选择。选择页打开时位于当前 workspace,并通过[跨 workspace 恢复](2026-07-28-cross-workspace-resume.md)记录所拥有的作用域切换到达其他每一个 workspace。 -`session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会复查日志、`cwd`、路由、当前 agent 的空闲状态,以及针对当前会话和已在本运行时中处于活跃状态的会话的排除规则,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。该预检不会锁定目标,也不会排除其他进程。 +`session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会复查日志、workspace、路由、当前 agent 的空闲状态,以及针对当前会话和已在本运行时中处于活跃状态的会话的排除规则,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。该预检不会锁定目标,也不会排除其他进程。 -预检通过后,TUI 会刷写当前会话,再次确认其 agent 仍处于空闲状态,然后停止终端并调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,而不是启动子进程。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。 +预检通过后,TUI 会刷写当前会话,再次确认其 agent 仍处于空闲状态,然后停止终端并以经过验证的 id 和目标 workspace 调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,而不是启动子进程。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。 -`resumeCommand` 保留为退出及无宿主时的回退方案。TUI 仅为显示目的替换 `{session}`,绝不执行任意 shell 文本。只有当前会话已经持久化时,退出提示才会出现。 +退出时打印的行是启动器拥有的上下文插槽,而非配置模板;不支持原地交接的宿主会说明会话仍可恢复,而不再给出它无法构造的命令。[由启动器持有的会话身份与退出行](../architecture/2026-07-28-launcher-owned-resume-identity.md)记录了这次所有权迁移,并取代本记录最初交付的 `resumeCommand` 配置键。TUI 仍然绝不执行 shell 文本。 ## Alternatives considered -**让 TUI 创建 `resumeCommand` 进程。** 否决:该模板是部署文本,不是可信的参数列表,且 TUI 不拥有应用拆卸或进程生命周期。受约束的宿主接口只接收经过验证的 `SessionId`。 +**让 TUI 创建恢复命令进程。** 否决:该文本是展示用文案,不是可信的参数列表,且 TUI 不拥有应用拆卸或进程生命周期。受约束的宿主接口只接收经过验证的 `SessionId`。 **在现有 TUI 内构造恢复后的 agent。** 否决:在表现层替换由配置创建的 agent,会跨越 Loader 所有权、作用域插件初始化、持久化资源释放和终端生命周期。释放根应用并替换进程可以复用受支持的启动路径。 @@ -36,4 +36,4 @@ Status: implemented ## Testing -TUI 测试覆盖键盘导航、标题/id 搜索、清空搜索后再取消、agent 运行期间拒绝恢复、拒绝恢复当前会话和已在本运行时中处于活跃状态的会话、路由缺失、损坏的候选行、预检复查、回退命令,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定全屏选择页和输入法光标锚点,真实 PTY smoke 则覆盖搜索与交接。 +TUI 测试覆盖键盘导航、标题/id 搜索、清空搜索后再取消、agent 运行期间拒绝恢复、拒绝恢复当前会话和已在本运行时中处于活跃状态的会话、路由缺失、损坏的候选行、预检复查、无宿主时的告警,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定全屏选择页和输入法光标锚点,真实 PTY smoke 则覆盖搜索与交接。 diff --git a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml index 4532ab2148..d8ac4d0dca 100644 --- a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-24-configurable-tui-prompt-theme.md: 4008f23a3f545e9b4484f0fa3f8490ad9b2c5541 -2026-07-24-configurable-tui-prompt-theme.zh.md: daf15b54d7e04d3860eacad47b754b963cde36fa +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md +2026-07-24-configurable-tui-prompt-theme.md: f8815c6c1904c47ebb899c3da7ac62a50f7f88f0 +2026-07-24-configurable-tui-prompt-theme.zh.md: 831471860a9dcd8bb10408c492e1fc6299af6262 diff --git a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md index 4008f23a3f..f8815c6c19 100644 --- a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md +++ b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md @@ -16,7 +16,7 @@ The TUI theme groups `color`, `truecolor`, `leftPrompt`, `rightPrompt`, `inputPr Registered fragments are trusted ANSI-capable presentation output. Template literals and ordinary external content remain sanitized, but a prompt-value plugin may emit terminal controls. Composite values own coordinated background transitions and separators, so one `${powerline}` value can render a complete Powerline segment without coupling adjacent atomic providers. -The built-in `cwd`, `git/worktree`, `token_meter/cache_hit_rate`, `model`, `context`, `timing`, styled `symbol` label, and `indicator` caret values use the same registry. Session and agent events update their handles, while the running timer updates `timing` and the animated `indicator` each tick. The shipped input template is `${symbol} ${indicator}`, preserving the existing `dsh > ` prefix. +The built-in `cwd`, `git/worktree`, `token_meter/cache_hit_rate`, `model`, `context`, `queued`, styled `symbol` label, and `indicator` caret values use the same registry. Session and agent events update their handles, while the running timer updates `queued` — the steering-queue badge, unavailable unless a running turn has queued messages — and the animated `indicator` each tick. The shipped input template is `${symbol} ${indicator}`, preserving the existing `dsh > ` prefix. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md index daf15b54d7..831471860a 100644 --- a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md @@ -16,7 +16,7 @@ TUI 主题把 `color`、`truecolor`、`leftPrompt`、`rightPrompt`、`inputPromp 注册的片段被视为可信的、允许携带 ANSI 的呈现输出。模板中的字面文本与普通外部内容仍会被清洗,但提供提示符值的插件可以输出终端控制序列。复合值自行负责协调背景色过渡与分隔符,因此一个 `${powerline}` 值就能渲染完整的 Powerline 段,而无需与相邻的原子提供方耦合。 -内置的 `cwd`、`git/worktree`、`token_meter/cache_hit_rate`、`model`、`context`、`timing`、带样式的 `symbol` 标签与 `indicator` 光标符值使用同一个注册表。会话与 agent(智能体)事件更新各自的句柄,运行计时器每一拍更新 `timing` 与带动画的 `indicator`。随附的输入模板为 `${symbol} ${indicator}`,保留了原有的 `dsh > ` 前缀。 +内置的 `cwd`、`git/worktree`、`token_meter/cache_hit_rate`、`model`、`context`、`queued`、带样式的 `symbol` 标签与 `indicator` 光标符值使用同一个注册表。会话与 agent(智能体)事件更新各自的句柄,运行计时器每一拍更新 `queued`——转向队列徽标,仅在运行中的一轮有排队消息时才可用——与带动画的 `indicator`。随附的输入模板为 `${symbol} ${indicator}`,保留了原有的 `dsh > ` 前缀。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml new file mode 100644 index 0000000000..cbac9d1251 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-tmux-location-context.md +2026-07-27-tmux-location-context.md: b6f0b0cc85fa4808bd761f30bdbab8ad65e61717 +2026-07-27-tmux-location-context.zh.md: e79214e03296a87c622df91437385950c00eded6 diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md new file mode 100644 index 0000000000..b6f0b0cc85 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md @@ -0,0 +1,61 @@ +# Agent Note: tmux-location context + +Status: implemented + +English | [中文](2026-07-27-tmux-location-context.zh.md) + +## Problem + +An agent running inside tmux has no way to tell the model where it is: which session, window, and pane the process occupies, and how the window is laid out. A user directing several panes wants the model to orient itself to its own location so instructions like "the pane below" or "this window" resolve. The location must reach the model as durable, reconstructable context, not a system-prompt value rewritten in place, and must cost nothing when the location has not changed. + +tmux exposes this without a daemon: `$TMUX_PANE` names the process's pane, and `tmux display-message -t "$TMUX_PANE" -p ''` prints any pane/window/session field. The open question was how to observe it — pull on each preparation, or push from a tmux hook — and how to avoid a per-step token cost and hidden process-local state. + +## Decision + +`@deepseek-ai/dsh-tmux-context` is an opt-in function plugin in `packages/context/tmux-context/`, alongside the other bounded request-context enrichments that define neither a tool nor a service. Shipped examples do not mount it because tmux-location disclosure and its token cost are deployment policy. + +**Pull on the first step of each turn, not a tmux push.** The plugin prepends an `agent/step` listener and acts only when `step === 1`. A pull model needs no background process, no hook installation in the user's tmux, and no teardown; it re-reads current state each turn so a moved, renamed, or re-laid-out pane is picked up naturally. Gating on the first step makes the reading per-turn: a location is stable within a turn, and re-querying every step would add cost without new information. A pane moved mid-turn is reflected on the next turn, which is the accepted tradeoff for the simpler design. + +**Read through the `ctx.bash` seam, never raw `child_process`.** The listener runs the tmux/`ps` read commands through `ctx.bash`, so the deployment's sandbox and policy apply and the plugin owns no subprocess code. Absent `ctx.bash`, absent tmux env, a wrong field count, or an empty pane id each make the attempt a no-op, matching how `workspace-context` no-ops without an `fs` provider. + +**Detect a real pane by tty, not by `$TMUX_PANE` alone.** `$TMUX_PANE` is inherited: a terminal launched from a tmux shell (a VS Code integrated terminal, a desktop launcher) carries `$TMUX`/`$TMUX_PANE` from that ancestor even though the process does not live in that pane, which otherwise injects a stale, wrong location. The command resolves this process's controlling terminal with `ps -o tty= -p ` (the agent's own pid, passed in-process) and compares it to the pane's `#{pane_tty}`; fields are emitted only on a match. A genuine pane owns this process's tty; an inherited environment names some other pane's tty and reads as "not in tmux". Checking `$TMUX` instead does not help — it is inherited identically. This is the definitive discriminator and needs no allowlist of terminal emulators. + +**Own location and layout only.** The queried fields are session name, window index/name, pane index/id, window/pane active flags, and `window_layout`. Pane and window pixel sizes are excluded (layout tree conveys structure; sizes are noisy and change on every terminal resize). Sibling-pane contents are never captured (`capture-pane`), keeping the reading small and avoiding scraping unrelated, possibly sensitive, output. + +**Inject only on change, with optional interval floor.** When due, the plugin calls `agent.inject()` for one `user/message` with source `{ kind: 'plugin', plugin: 'tmux-context' }`. Change suppression compares the rendered state block (everything after the turn preamble line) against the latest injection of this source, found by scanning raw durable session events — so the schedule survives compaction and process resume without a process-local cache. The optional `refreshIntervalMs` (manually validated as a non-negative safe integer at plugin load) additionally suppresses injections within that window of the latest one. + +### Text + +```text +tmux location (turn ): +session , window "", pane +window active=<0|1>, pane active=<0|1>, layout +``` + +The turn preamble is the volatile first line; the two-line state block below it is the unit compared for change suppression, so re-injection is driven by tmux state, not loop position. + +### Durability and request reconstruction + +Each reading is a normal surface node until compaction shadows it; the plugin contributes nothing to system-prompt assembly and `request/header` carries no tmux-context text. The reading records a preparation attempt, not a committed step: because the prepended listener runs first, its append may remain when a later `agent/step` listener cancels or fails the attempt, and the append-only log performs no rollback. + +The published `./invariant` companion registers no runtime check: a reading is a per-turn snapshot of external tmux state, so the session holds no cross-event relation to validate, and scheduling and format stay pinned by the package's pipeline tests. + +## Consequences + +An agent booted inside tmux now receives its own session/window/pane location and window layout as durable, source-attributed context, updated per turn when the location changes. Deployments opt in through cordis.yml; the default spine and shipped examples stay silent. Outside a real tmux pane — including a terminal that merely inherited `$TMUX`/`$TMUX_PANE` — or without a `ctx.bash` executor, the plugin is inert with no error, so composing it is safe everywhere. Because the reading is one durable `user/message`, it survives compaction as ordinary history, contributes nothing to system-prompt assembly or request headers, and costs at most one two-line message per changed turn. The pull model adds one bash execution (through the sandboxed bash seam) on the first step of each turn that is due — internally a `ps` tty probe, a `tmux display-message` tty query, and the field query. Only the optional interval floor suppresses the query itself; an unchanged location is known only after querying, so it suppresses the injection alone. + +## Testing + +Unit tests pin: first-step injection and source/surface metadata; the `$TMUX_PANE`-keyed command including its `#{pane_tty}`-vs-`ps -o tty=` guard; step-gating; change suppression across turns and re-injection on a moved pane; positive-interval suppression and threshold; every no-op path (no bash, nonzero exit, wrong field count, empty pane id, aborted signal); prepended ordering before ordinary `agent/step` listeners; resilience to a corrupt prior reading (non-text block, single-line text); and config rejection of negative and non-integer intervals. Per-file coverage is 100%. + +## Alternatives considered + +- **Push from a tmux hook / background watcher** — rejected: requires installing hooks in the user's tmux and a background process with teardown, to gain mid-step freshness that per-turn context does not need. +- **Run every step** — rejected: location is stable within a turn; re-querying adds token cost without new information. Gating on `step === 1` yields per-turn readings. +- **Raw `child_process`** — rejected: bypasses the sandbox/policy seam and hand-rolls subprocess code the `ctx.bash` executor already owns. +- **Include pane/window pixel sizes** — rejected: sizes churn on every resize and add noise; the layout tree already conveys structure. +- **Scrape sibling panes with `capture-pane`** — rejected: large, noisy, and privacy-sensitive; out of scope for "own location". +- **Dynamic system-prompt section** — rejected: replacing a value erases the earlier readings behind prior reasoning and is not reconstructable; one durable attributed message records each location where it became visible. +- **Trust `$TMUX_PANE` (or `$TMUX`) presence** — rejected: both are inherited by terminals launched from a tmux shell (VS Code integrated terminal), so a non-pane process injects a stale location. The pane `#{pane_tty}` vs. this process's controlling tty is the definitive check. +- **Denylist known terminal emulators (e.g. `TERM_PROGRAM=vscode`)** — rejected: a partial, ever-growing list that still misses other launchers; the tty match is exact and launcher-agnostic. +- **A runtime invariant validating each reading's turn, position, and format** — shipped initially, then removed: it re-derived the producer's own scheduling from the log and asserted a regex over text the same package had just rendered, so it restated `apply()` rather than checking an independent relation. Every failure it could report required an edit to this package, which its pipeline tests already catch. Reintroduce a companion check only for a relation the plugin does not itself compute — for example if readings gain cross-turn ordering or enclosure obligations that another package can violate. diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md new file mode 100644 index 0000000000..e79214e032 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md @@ -0,0 +1,61 @@ +# Agent Note:tmux 位置上下文 + +Status: implemented + +[English](2026-07-27-tmux-location-context.md) | 中文 + +## 问题 + +运行在 tmux 内的 agent 无法告诉模型自己身在何处:进程占据哪个 session、window、pane,以及 window 如何布局。当用户操作多个 pane 时,希望模型能对自身位置有所定位,从而让"下方的 pane""这个 window"之类的指令得以解析。位置必须以持久、可重建的上下文形式送达模型,而非在原地被改写的系统提示值,并且当位置未变化时不产生任何成本。 + +tmux 无需守护进程即可暴露这些信息:`$TMUX_PANE` 标识进程所在 pane,`tmux display-message -t "$TMUX_PANE" -p ''` 可打印任意 pane/window/session 字段。待决问题在于如何观测——在每次准备时拉取,还是由 tmux hook 推送——以及如何避免逐步骤 token 成本与隐藏的进程内状态。 + +## 决策 + +`@deepseek-ai/dsh-tmux-context` 是位于 `packages/context/tmux-context/` 的可选启用型函数插件,与其他既不定义工具也不定义服务的有界请求上下文增强并列。随附示例不挂载它,因为 tmux 位置披露及其 token 成本属于部署策略。 + +**在每轮的第一个 step 拉取,而非 tmux 推送。** 插件前置注册一个 `agent/step` 监听器,仅在 `step === 1` 时动作。拉取模型无需后台进程、无需在用户的 tmux 中安装 hook、也无需清理;它每轮重新读取当前状态,因此被移动、改名或重新布局的 pane 都会被自然感知。以第一个 step 为门槛使读数按轮次生成:位置在一轮内是稳定的,逐步骤重复查询只会增加成本而不带来新信息。轮次中途移动的 pane 会在下一轮反映,这是换取更简单设计所接受的取舍。 + +**通过 `ctx.bash` seam 读取,绝不用裸 `child_process`。** 监听器通过 `ctx.bash` 运行 tmux/`ps` 只读命令,从而应用部署方的沙箱与策略,插件不拥有任何子进程代码。`ctx.bash` 缺失、tmux 环境缺失、字段数不符或 pane id 为空,都会使本次尝试成为空操作,与 `workspace-context` 在无 `fs` provider 时的空操作一致。 + +**以 tty 判定真实 pane,而非仅凭 `$TMUX_PANE`。** `$TMUX_PANE` 会被继承:从 tmux shell 启动的终端(VS Code 集成终端、桌面启动器)会从该祖先进程带上 `$TMUX`/`$TMUX_PANE`,即使进程并不位于那个 pane 中,否则就会注入一个陈旧且错误的位置。命令用 `ps -o tty= -p `(在进程内传入 agent 自身的 pid)解析本进程的控制终端,并与 pane 的 `#{pane_tty}` 比较;只有匹配时才输出字段。真正的 pane 拥有本进程的 tty;继承而来的环境指向的是另一个 pane 的 tty,因而被读作"不在 tmux 中"。改为检查 `$TMUX` 也无济于事——它同样会被继承。这是决定性的判别依据,且无需维护终端模拟器名单。 + +**仅自身位置与布局。** 查询字段为 session name、window index/name、pane index/id、window/pane 活动标志以及 `window_layout`。省略 pane 与 window 像素尺寸(布局树已传达结构;尺寸嘈杂且每次终端缩放都会变化)。从不采集相邻 pane 内容(`capture-pane`),使读数保持小巧,并避免抓取无关、可能敏感的输出。 + +**仅在变化时注入,并可选间隔下限。** 需要时,插件调用 `agent.inject()` 注入一条来源为 `{ kind: 'plugin', plugin: 'tmux-context' }` 的 `user/message`。变化抑制将渲染出的状态块(轮次前缀行之后的全部内容)与该来源的最近一次注入比较,后者通过扫描原始持久会话事件获得——因此调度可跨压缩与进程恢复存续,无需进程内缓存。可选的 `refreshIntervalMs`(在插件加载时手动校验为非负安全整数)会额外抑制距最近一次注入不足该窗口的注入。 + +### 文本 + +```text +tmux location (turn ): +session , window "", pane +window active=<0|1>, pane active=<0|1>, layout +``` + +轮次前缀是易变的首行;其下的两行状态块才是变化抑制所比较的单元,因此重新注入由 tmux 状态驱动,而非循环位置。 + +### 持久性与请求重建 + +每条读数在被压缩遮蔽前都是普通表层节点;插件对系统提示装配毫无贡献,`request/header` 也不携带任何 tmux-context 文本。读数记录的是一次准备尝试,而非已提交的 step:由于前置监听器最先运行,当后续 `agent/step` 监听器取消或失败时其追加可能仍会保留,只追加的日志不做回滚。 + +发布的 `./invariant` 伴生插件不注册任何运行时检查:读数是外部 tmux 状态的按轮快照,会话中不存在需要校验的跨事件关系,调度与格式由本包的管线测试固定。 + +## 后果 + +启动于 tmux 内的 agent 现在会以持久、带来源标记的上下文收到自身的 session/window/pane 位置及 window 布局,并在位置变化时按轮次更新。部署方通过 cordis.yml 选择启用;默认 spine 与随附示例保持沉默。在真实 tmux pane 之外——包括仅继承了 `$TMUX`/`$TMUX_PANE` 的终端——或没有 `ctx.bash` 执行器时,插件保持惰性且不报错,因此在任何地方组合它都安全。由于读数是一条持久的 `user/message`,它作为普通历史经受压缩,对系统提示装配与请求头毫无贡献,且每个发生变化的轮次至多花费一条两行消息。拉取模型在每个到期轮次的第一个 step 增加一次 bash 执行(经沙箱化的 bash seam)——内部包含一次 `ps` tty 探测、一次 `tmux display-message` tty 查询和字段查询。只有可选的间隔下限会抑制查询本身;位置是否变化只有在查询之后才知道,因此它只抑制注入。 + +## 测试 + +单元测试固定了:首个 step 的注入及来源/表层元数据;以 `$TMUX_PANE` 为键的命令(含其 `#{pane_tty}` 与 `ps -o tty=` 的比对守卫);step 门槛;跨轮次的变化抑制与 pane 移动时的重新注入;正间隔抑制与阈值;每条空操作路径(无 bash、非零退出、字段数不符、pane id 为空、信号已取消);前置排序先于普通 `agent/step` 监听器;对损坏的历史读数(非文本块、单行文本)的容错;以及配置对负值与非整数间隔的拒绝。逐文件覆盖率为 100%。 + +## 考虑过的替代方案 + +- **由 tmux hook / 后台监视器推送**——否决:需要在用户的 tmux 中安装 hook,并引入带清理的后台进程,只为换取按轮次上下文并不需要的步内新鲜度。 +- **每个 step 都运行**——否决:位置在一轮内稳定;重复查询只增加 token 成本而无新信息。以 `step === 1` 为门槛得到按轮次读数。 +- **裸 `child_process`**——否决:绕过沙箱/策略 seam,并手写 `ctx.bash` 执行器已拥有的子进程代码。 +- **包含 pane/window 像素尺寸**——否决:尺寸每次缩放都变动、徒增噪声;布局树已传达结构。 +- **用 `capture-pane` 抓取相邻 pane**——否决:庞大、嘈杂且涉及隐私;超出"自身位置"范围。 +- **动态系统提示区块**——否决:替换某个值会抹去支撑先前推理的历史读数且不可重建;单条持久且带来源的消息在每个位置变得可见时予以记录。 +- **信任 `$TMUX_PANE`(或 `$TMUX`)存在即可**——否决:两者都会被从 tmux shell 启动的终端(VS Code 集成终端)继承,于是非 pane 进程会注入陈旧位置。pane 的 `#{pane_tty}` 与本进程控制终端的比对才是决定性检查。 +- **对已知终端模拟器设黑名单(如 `TERM_PROGRAM=vscode`)**——否决:名单不完整且会不断增长,仍会漏掉其他启动器;tty 比对精确且与启动器无关。 +- **用运行时 invariant 校验每条读数的轮次、位置与格式**——最初随包发布,随后移除:它从日志中重新推导生产者自身的调度,并对同一个包刚刚渲染出的文本断言正则,因此只是重述 `apply()`,而非检查一条独立关系。它能报出的每种失败都必须先修改本包,而这些本包的管线测试已经覆盖。仅当出现插件自身并不计算的关系时才重新引入伴生检查——例如读数将来具备可被其他包破坏的跨轮次顺序或包裹义务。 diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml index 820645dc95..ed6406245e 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md -2026-07-27-tui-tool-card-header.md: 0868d8dcaf5ab1641a122ecda05578aef46f5f94 -2026-07-27-tui-tool-card-header.zh.md: 71db5fc6fc853039ea9be1cb1c51686f941c4cb7 +2026-07-27-tui-tool-card-header.md: 13f5e7fec1a82d02d5bf8cae4784505c02ab6f38 +2026-07-27-tui-tool-card-header.zh.md: 85f9f1244a7da568f5dcc892729021ac0fa270c9 diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md index 0868d8dcaf..13f5e7fec1 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md +++ b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md @@ -24,7 +24,7 @@ The redesign is TUI-only. It touches `ToolCardComponent` in `packages/ui/tui/src **Keep the presenter title in the header** (e.g. `Tool / read / Read src/index.ts`). Rejected: the verb duplicates the tool name, and non-bash tools have no genuinely distinct one-line description — the target belongs in the body, so only bash contributes a header desc. -**A summary footer for every card type** (line counts, exit pills, diff counts as a uniform `└ …` line). Deferred: only the diff footer shipped. Terminal exit keeps its existing dim `[exit N]` line, long output keeps its existing head+tail middle-elision, an empty result stays header-only, and an error body stays plain (only the header color carries the error) — the current treatments were kept deliberately, not by omission. +**A summary footer for every card type** (line counts, exit pills, diff counts as a uniform `└ …` line). Deferred: only the diff footer shipped. Terminal exit keeps its existing dim `[exit N]` line, long output keeps its existing head+tail middle-elision, an empty result stays header-only, and an error body stays plain (only the header color carries the error) — the current treatments were kept deliberately, not by omission. The body's flat default-foreground styling was later revisited: the [consolidated TUI presentation](../architecture/2026-07-28-consolidated-tui-presentation.md) recesses the whole body into one dim tone under this note's colored status header. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md index 71db5fc6fc..85f9f1244a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md @@ -24,7 +24,7 @@ TUI 曾把每次工具调用渲染为 `{glyph} {title}`,其中 `title` 是 pre **把 presenter 标题保留在表头**(例如 `Tool / read / Read src/index.ts`)。已否决:动词与工具名重复,而非 bash 工具并没有真正独立的单行描述——操作对象属于正文,因此只有 bash 向表头贡献描述段。 -**为每一种卡片都加一条汇总页脚**(行数、退出码徽章、diff 计数统一为一条 `└ …` 行)。已推迟:仅 diff 页脚落地。终端退出保留其既有的变暗 `[exit N]` 行,长输出保留其既有的首尾中段省略,空结果保持仅表头,错误正文保持朴素(仅表头颜色承载错误)——这些既有处理是有意保留的,而非遗漏。 +**为每一种卡片都加一条汇总页脚**(行数、退出码徽章、diff 计数统一为一条 `└ …` 行)。已推迟:仅 diff 页脚落地。终端退出保留其既有的变暗 `[exit N]` 行,长输出保留其既有的首尾中段省略,空结果保持仅表头,错误正文保持朴素(仅表头颜色承载错误)——这些既有处理是有意保留的,而非遗漏。正文原本以默认前景色平铺,这种样式后来也经过调整:[整合后的 TUI 呈现](../architecture/2026-07-28-consolidated-tui-presentation.md)把整个正文收进本文所述彩色状态标题之下的同一种暗色调。 ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml new file mode 100644 index 0000000000..60285a26b0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md +2026-07-28-cross-workspace-resume.md: 09b638398ea9379d39df94fcb42da3395cdd70df +2026-07-28-cross-workspace-resume.zh.md: 5a2e7d2535c07b4ace0416b234dc28b32cbcd2fc diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md new file mode 100644 index 0000000000..09b638398e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md @@ -0,0 +1,52 @@ +# Agent Note: Cross-workspace session resume + +Status: implemented + +English | [中文](2026-07-28-cross-workspace-resume.zh.md) + +## Problem + +`/resume` could only reach sessions started in the launch directory, so returning to yesterday's work in another project meant remembering its path, leaving the TUI, and relaunching there. Two independent causes produced that limit, and fixing either alone changes nothing. + +Storage was the binding one. The shipped `tui-demo` bundle defaulted `persistenceRoot` to a relative `./.sessions`, so each launch directory owned a disjoint JSONL root and a disjoint derived `session-query.db`. Sessions from another project were not filtered out of the listing — they were absent from the store the listing reads. The JSONL backend already partitions per-cwd *inside* one root, so the partitioning was doubled: once by root, once within it. + +The picker then filtered again. It dropped records whose `cwd` differed from the current session before display, and `summarizeResumeCandidate` independently marked a differing `cwd` as `disabledReason: 'different workspace'`, so a foreign session that did reach the store was both hidden and refused. + +Finally, resume never changed directory. The host re-execs `dsh --resume=` through `process.execve`, which inherits the cwd. Session *header* cwd is restored from the log, but process cwd is what `dsh-fs-local`, the bash executor, and glob/grep resolve against, so resuming a foreign session would have replayed its transcript while acting on the wrong project. + +## Decision + +The dsh launcher supplies one session root under its Harness home through a boot slot, the picker gains a workspace scope, and the handoff carries the target directory. + +**Storage.** `dsh-paths` owns the location as `resolveSessionsRoot()` (`sessions` under the Harness home, by `resolveDshHome`'s precedence), but only the launcher assumes it: shared-store policy is the dsh CLI's, never a plugin's. The TUI surface provides the root through the `SESSIONS_ROOT_KEY` boot slot (`ctx.provide` before Loader entries mount) and `dsh web` patches the same root in `apps/cli/src/app-cli-entry.ts`. Two CLI surfaces computing that path independently is exactly the failure this change fixes — disjoint stores — so the fact gets one home rather than a `join` per caller, alongside the existing `registryRoot()` precedent for `run`. + +`tui-demo` itself keeps a project-local `./.sessions` default and reads the launcher slot between explicit config and that default (`config.persistenceRoot ?? ctx.get(SESSIONS_ROOT_KEY) ?? './.sessions'`). The precedence lives in `composeTuiApp`, not as a schemastery `.default()`, because a schema default would materialize before the compose function runs and shadow the slot for every Loader mount. `examples/tui-agent/cordis.yml` omits `persistenceRoot` so the launcher slot (or, for a bare example boot, the project-local default) applies. Configuring an explicit root always wins, which remains the correct choice for a hermetic deployment. + +**Scope, not exclusion.** A workspace other than the current one is a display scope rather than a disabled reason. `showResume()` summarizes every record and the `ResumePicker` owns a `scope` of `'workspace' | 'all'`, defaulting to the current workspace so the common case is unchanged. Tab toggles; the scope line names the active scope and the count the other holds; each row in the all-workspaces scope reports its own workspace, and that label joins the searchable text only in the scope that shows it. A toggle clears the query and selection so the highlighted row always belongs to the visible list, and the per-row workspace line makes a row one terminal row taller in that scope, which the visible-count budget accounts for. + +`summarizeResumeCandidate` therefore drops `'different workspace'` and gains `'session has no recorded workspace'`. That is a real new refusal rather than a rename: a header without `cwd` names no directory for the host to enter, so it cannot be handed off even though its log is intact. + +**Handoff.** `TuiResumeHost.handoff` takes the target `cwd` beside the `SessionId`. `preflightResume` resolves both together and returns them, so the caller cannot re-derive a stale directory from the row it displayed — a record whose `cwd` moved between listing and preflight is resumed in the *re-read* directory, which is why the former "reject a moved cwd" behavior is now a handoff with the new path. The shipped host chdirs before disposing the app: an unreachable directory must reject while the caller can still restore the terminal, because after teardown no owner remains to report to. `resumeArgs` keeps the `meta` subcommand form only when the target is this checkout, since `dsh meta` chdirs to the harness source itself and would override any other workspace. + +## Alternatives considered + +**Patch `persistenceRoot` from the `dsh` launcher instead of changing the bundle default.** Rejected after finding that a loader patch assigns `config` wholesale. The personal `~/.dsh/config.yaml` overlay already patches the `tui-agent` row with a partial config, which is exactly why `persistenceRoot` was falling back to the bundle default in the first place; a launcher patch would either be erased by that overlay or have to win over it and make the overlay unable to set the field. Owning the default in the bundle survives any partial patch and keeps one home for the fact. + +**Keep `./.sessions` and additionally scan the Harness-home root.** Rejected: two roots means two SQLite indexes and a merged listing whose rows have different liveness and revision authorities, to preserve visibility of logs that the no-migration decision already gives up. + +**Migrate existing project-local logs into the shared root.** Rejected by the requester. Sessions under a project's `./.sessions` stay on disk and stay resumable by explicit `dsh --resume ` from that directory, but no longer appear in `/resume`. + +**One flat list of every workspace.** Rejected: it loses the "this project" default that the overwhelmingly common case wants, and in a busy home directory the current project's sessions would compete with unrelated ones. + +**Let the host infer the directory from the restored session header.** Rejected: the header is model- and prompt-facing state restored *after* boot, while the directory must be entered *before* `execve`. Passing it explicitly keeps the ordering visible at the seam. + +## Consequences + +- Sessions already stored under a project-local `./.sessions` disappear from `/resume`. This is the accepted cost of no migration. +- One shared root makes the pre-existing absence of a cross-process session lock reachable in one step: colliding used to require two terminals in the same directory, and is now one Tab away. `record.live` comes from the in-process `SessionQueryService`, so preflight rejects only sessions live in *this* runtime, while the JSONL backend takes no lock and two processes appending one log with independent `seq` counters would interleave. Closing this is no longer speculative hardening: `SessionRegistry.list()` already publishes live sessions cross-process under the same Harness home for `dsh list-sessions`, so consulting it in `summarizeResumeCandidate` is a small follow-up. It stays out of this change as pre-existing scope. +- A resumed session can change the process's working directory, so a foreign resume is not a pure transcript restoration — every path-resolving tool moves with it. +- The Harness home now holds session logs for every project on the machine. Its growth is no longer bounded by one checkout, and no retention policy is introduced here. + +## Testing + +TUI tests cover the default scope hiding other workspaces while reporting their count, Tab revealing them with per-row workspace labels, Tab back clearing the query and selection, searching by workspace label, a cwd-less record staying visible but disabled, and the handoff receiving both the id and the workspace re-read at preflight. The former "reject a moved cwd" case now asserts the handoff carries the new directory. `dsh-paths` tests pin `resolveSessionsRoot`'s precedence against `resolveDshHome`'s. `tui-demo` composition tests pin the project-local default and the derived `session-query.db` path. The keyless TUI snapshot pins both scopes of the selector, including the scope line, the per-row workspace lines, and the Tab hint in the footer. A manual cross-workspace resume verified at the process level that the replacement's working directory became the target workspace. diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md new file mode 100644 index 0000000000..5a2e7d2535 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md @@ -0,0 +1,52 @@ +# Agent Note: 跨 workspace 会话恢复 + +Status: implemented + +[English](2026-07-28-cross-workspace-resume.md) | 中文 + +## Problem + +`/resume` 只能触达在启动目录中创建的会话,因此要回到昨天在另一个项目里的工作,就得记住它的路径、退出 TUI、再到那里重新启动。造成这一限制的原因有两个,彼此独立,只修其中一个都不会有任何变化。 + +存储是那个决定性的原因。已交付的 `tui-demo` 组合包把 `persistenceRoot` 默认成相对路径 `./.sessions`,于是每个启动目录都独占一份互不相交的 JSONL 根目录,以及一份互不相交的派生 `session-query.db`。来自另一个项目的会话并不是在列表中被过滤掉的——它们根本不存在于列表读取的存储中。JSONL 后端本来就会在*同一个*根目录*内部*按 cwd 分区,所以分区被叠加了两层:一层按根目录,一层在根目录内部。 + +接着选择器又过滤了一次。它在展示前丢弃 `cwd` 与当前会话不同的记录,而 `summarizeResumeCandidate` 又独立地把不同的 `cwd` 标记为 `disabledReason: 'different workspace'`,于是一个确实进入了存储的外部会话既被隐藏,也会被拒绝。 + +最后,恢复流程从不切换目录。宿主通过 `process.execve` 重新执行 `dsh --resume=`,而它会继承 cwd。会话*头部*的 cwd 会从日志中还原,但 `dsh-fs-local`、bash 执行器以及 glob/grep 解析路径时依据的是进程 cwd,所以恢复一个外部会话会在回放它的 transcript(文本记录)的同时,作用到错误的项目上。 + +## Decision + +dsh 启动器通过启动槽位提供其 Harness home 下的同一个会话根目录,选择器获得 workspace 范围,交接过程携带目标目录。 + +**存储。** `dsh-paths` 以 `resolveSessionsRoot()` 拥有该位置(按 `resolveDshHome` 的优先级,取 Harness home 下的 `sessions`),但只有启动器假定它:共享存储策略属于 dsh CLI,绝不属于插件。TUI 界面通过 `SESSIONS_ROOT_KEY` 启动槽位(在 Loader 条目挂载前 `ctx.provide`)提供该根目录,`dsh web` 则在 `apps/cli/src/app-cli-entry.ts` 中为同一根目录打补丁。CLI 的两处界面各自独立计算该路径,正是本次改动所修复的那种失败——互不相交的存储——因此这项事实只有一个归属,而不是每个调用方各做一次 `join`,这与 `run` 已有的 `registryRoot()` 先例一致。 + +`tui-demo` 自身保持项目本地的 `./.sessions` 默认值,并在显式配置与该默认值之间读取启动器槽位(`config.persistenceRoot ?? ctx.get(SESSIONS_ROOT_KEY) ?? './.sessions'`)。这一优先级放在 `composeTuiApp` 内,而不是写成 schemastery 的 `.default()`,因为 schema 默认值会在 compose 函数运行前物化,使每次 Loader 挂载都遮蔽该槽位。`examples/tui-agent/cordis.yml` 不写 `persistenceRoot`,因此启动器槽位(裸示例启动时则为项目本地默认值)生效。显式配置的根目录总是获胜,对于封闭部署来说这仍然是正确的选择。 + +**是范围,不是排除。** 当前 workspace 之外的 workspace 是一种展示范围,而不是禁用理由。`showResume()` 汇总每一条记录,`ResumePicker` 持有一个 `'workspace' | 'all'` 的 `scope`,默认为当前 workspace,因此常见场景毫无变化。Tab 切换范围;范围行会说明当前生效的范围,以及另一个范围下的数量;在全 workspace 范围中每一行都报告自己的 workspace,而该标签只在展示它的范围里才加入可搜索文本。切换范围会清空查询和选中项,使高亮行始终属于可见列表;而逐行的 workspace 行会让该范围下的每一行在终端里多占一行,可见条数预算已经把这一点计入。 + +因此 `summarizeResumeCandidate` 去掉了 `'different workspace'`,并新增 `'session has no recorded workspace'`。这是一条真正新增的拒绝理由,而不是改名:没有 `cwd` 的头部没有指明任何目录供宿主进入,所以即便它的日志完好也无法完成交接。 + +**交接。** `TuiResumeHost.handoff` 在 `SessionId` 之外还接收目标 `cwd`。`preflightResume` 把两者一起解析并一起返回,因此调用方无法从它展示过的那一行里重新推导出一个陈旧目录——在列表展示与预检之间 `cwd` 发生了移动的记录,会在*重新读取到的*目录中恢复,这也是原先「拒绝已移动的 cwd」的行为如今变成携带新路径完成交接的原因。已交付的宿主在释放应用之前切换目录:不可达的目录必须在调用方还能恢复终端时就拒绝,因为拆卸之后已经没有任何所有者可供汇报。`resumeArgs` 只在目标就是本 checkout 时才保留 `meta` 子命令形式,因为 `dsh meta` 会切换到 harness 源码本身,从而覆盖任何其他 workspace。 + +## Alternatives considered + +**从 `dsh` 启动器给 `persistenceRoot` 打补丁,而不是改动组合包默认值。** 在发现 loader 补丁会整体赋值 `config` 之后否决。个人的 `~/.dsh/config.yaml` 覆盖层已经用一份局部配置给 `tui-agent` 那一项打了补丁,这恰恰就是 `persistenceRoot` 一开始会退回到组合包默认值的原因;启动器补丁要么会被该覆盖层擦除,要么必须压过它,从而让覆盖层再也无法设置这个字段。把默认值放在组合包里能经受任何局部补丁,并让这项事实只有一个归属。 + +**保留 `./.sessions`,并额外扫描 Harness home 根目录。** 否决:两个根目录意味着两份 SQLite 索引,以及一份合并列表——其中各行的活跃状态与版本权威来源并不相同,而这一切只是为了保住不做迁移的决策本就已经放弃的那部分日志可见性。 + +**把现有的项目本地日志迁移到共享根目录。** 被需求方否决。项目 `./.sessions` 下的会话仍留在磁盘上,从该目录显式执行 `dsh --resume ` 仍可恢复,只是不再出现在 `/resume` 中。 + +**把所有 workspace 铺成一个扁平列表。** 否决:这会丢掉绝大多数场景想要的「本项目」默认值,而在一个繁忙的 home 目录里,当前项目的会话会和无关会话争夺注意力。 + +**让宿主从还原后的会话头部推断目录。** 否决:会话头部是面向模型与提示词的状态,在启动*之后*才还原,而目录必须在 `execve` *之前*进入。显式传递它能让这个顺序在边界处保持可见。 + +## Consequences + +- 已经存放在项目本地 `./.sessions` 下的会话会从 `/resume` 中消失。这是不做迁移所接受的代价。 +- 同一个共享根目录让原本就缺失的跨进程会话锁一步之内即可触达:过去要造成冲突需要在同一个目录里开两个终端,如今只差一次 Tab。`record.live` 来自进程内的 `SessionQueryService`,因此预检只会拒绝在*本*运行时中处于活跃状态的会话,而 JSONL 后端不加任何锁,两个进程用各自独立的 `seq` 计数器追加同一份日志会互相交错。解决这一点已不再是投机性加固:`SessionRegistry.list()` 已经为 `dsh list-sessions` 在同一个 Harness home 下跨进程发布活跃会话,因此在 `summarizeResumeCandidate` 中查询它是一项小的后续工作。它作为既有范围之外的问题不纳入本次改动。 +- 恢复一个会话可以改变进程的工作目录,因此恢复外部会话不是单纯的 transcript 还原——每个解析路径的工具都会随之移动。 +- Harness home 现在保存着这台机器上每个项目的会话日志。它的增长不再受单个 checkout 约束,而本记录也没有引入任何保留策略。 + +## Testing + +TUI 测试覆盖默认范围隐藏其他 workspace 但报告其数量、Tab 显示它们并带上逐行 workspace 标签、再按 Tab 返回时清空查询与选中项、按 workspace 标签搜索、无 cwd 的记录仍可见但不可选,以及交接同时收到 id 和在预检时重新读取到的 workspace。原先「拒绝已移动的 cwd」的用例现在断言交接携带新目录。`dsh-paths` 测试固定 `resolveSessionsRoot` 的优先级与 `resolveDshHome` 的一致。`tui-demo` 组合测试固定项目本地默认值以及派生出的 `session-query.db` 路径。无密钥 TUI 快照固定选择器的两个范围,包括范围行、逐行 workspace 行,以及页脚中的 Tab 提示。手动执行的一次跨 workspace 恢复在进程层面验证了替换后进程的工作目录变为目标 workspace。 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml new file mode 100644 index 0000000000..7e8ca82fae --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md +2026-07-28-dsh-guided-skill-session-commands.md: 338629f5a1adb9c1973daf87f2f52479bd70ba47 +2026-07-28-dsh-guided-skill-session-commands.zh.md: a9a8a70212dd92b9c850a529789f4e4879838090 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md new file mode 100644 index 0000000000..338629f5a1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md @@ -0,0 +1,43 @@ +# Agent Note: `dsh migrate`/`dsh upgrade` seed the first turn with a skill + +Status: implemented + +English | [中文](2026-07-28-dsh-guided-skill-session-commands.zh.md) + +## Problem + +Two recurring flows begin with the user manually invoking one skill and answering its questions: migrating from another coding agent, and upgrading this checkout. Both require the user to know the skill exists and to type `/skill:dsh-migrate` or `/skill:dsh-upgrade` as the session's first turn. A dedicated entry command that drops the user straight into that guided session removes the discovery step. + +## Decision + +`dsh migrate` and `dsh upgrade` boot the ordinary TUI as a fresh session whose first turn auto-invokes a bundled skill (`dsh-migrate`, `dsh-upgrade`), exactly as if the user typed `/skill:` and pressed Enter. + +The seed reuses the existing TUI skill path, not a new one. `createTuiChat` already has `invokeSkill(name, instructions)` — the code a typed `/skill:` runs, including the "Unknown skill" notice. The launcher passes the skill name to the app through a new boot-context slot `INITIAL_SKILL_KEY` (`tuiInitialSkill`), mirroring `MAIN_SESSION_ID_KEY`/`TUI_GOODBYE_MESSAGE_KEY`: `ctx.provide` is the only channel from launcher argv into a Loader-mounted plugin. The TUI's `apply()` reads the slot and folds it into `config.initialSkill`; after `ui.start()` succeeds, `createTuiChat` fires `invokeSkill(config.initialSkill, '')` once when set. + +**Freshness is gated in the launcher, not the TUI.** `runSkillSession` always mints a fresh session and provides the slot only when `resumeSessionId === undefined`, so a later `dsh --resume ` of that session is an ordinary TUI session with no re-injection. The TUI stays generic: it invokes whatever skill it is handed, once, at startup. + +**`migrate`/`upgrade` take no options.** Unlike `meta`, they carry no `--resume`, `--config`, or `-p`; a guided fresh-session entry has nothing to resume or reconfigure. Any leaked default-surface option fails loud, matching the `web`/`meta` rejection pattern in the Commander adapter. The two modes share one `SkillSessionInvocation` discriminant (`mode: 'migrate' | 'upgrade'`); `bin.ts` maps the mode to `dsh-${mode}`. + +The `dsh-migrate` skill is bundled under `skills/` (shipped through `DSH_BUNDLED_SKILL_DIR`, like `dsh-upgrade`). It asks which source agent (opencode/pi/Claude Code/Codex) if unstated, then maps each capability — workspace instructions, personal overlay, skills, hooks, MCP, API/env — to its DSH equivalent, grounded in the actual repo surfaces (the `hooks-claude`/`hooks-codex` bridges, `~/.dsh/{config.yaml,.env,AGENTS.md,skills/}`, `AGENTS.md`/`CLAUDE.md`, `mcporter`), and states plainly when a capability has no equivalent. + +## Testing + +`apps/cli/tests/args.spec.ts` gains routing for `migrate`/`upgrade` (bare discriminant) and exit-1 for every leaked option on either side of each subcommand. + +`packages/ui/tui/tests/tui.spec.ts` gains two fake-terminal cases in the existing skill describe block: `config.initialSkill` set delivers the rendered skill body as the first turn with no user input, and an unknown initial skill reports a notice without sending. `runSkillSession` itself is composition inside the module's `v8 ignore` block, like `runTui`/`runMeta`. + +No keyless PTY snapshot: per the maintainer's scope call for this change, unit coverage plus interactive verification suffices, and the seed rides the already-snapshotted `/skill:` render path. Both commands were verified interactively in tmux from a scratch cwd: `dsh migrate` loaded `dsh-migrate` and asked which source agent; `dsh upgrade` loaded `dsh-upgrade`, which pulled in `dsh-customize` and began checkout discovery. + +## Alternatives considered + +**Prefill the input and let the user press Enter.** Rejected: needs a new editor-prefill seam and still requires a keystroke. Auto-submit reuses `invokeSkill` and delivers the intended one-command entry. + +**Seed a natural-language instruction ("use the dsh-migrate skill…") instead of `/skill:`.** Rejected here: the literal skill-invocation path renders the skill body into the first turn deterministically, identical to the manual command, rather than depending on the model choosing to load the skill. + +**Support `--resume` on `migrate`/`upgrade`.** Rejected: these are one-shot guided entries. A resumed session is an ordinary TUI session reachable through the default surface's `dsh --resume `; re-injecting the skill on resume would duplicate the first turn. + +**Read `INITIAL_SKILL_KEY` in the app bundle (like `MAIN_SESSION_ID_KEY`) rather than in the TUI's `apply()`.** Not needed: `initialSkill` is a TUI `Config` field consumed in `createTuiChat`, so folding the slot into config at the TUI entry keeps it beside the other launcher-owned runtime reads (`tuiResumeHost`, `tuiGoodbyeMessage`) and leaves the app bundle unchanged. + +## Consequences + +Migrating or upgrading is one command from anywhere, with the guiding skill already invoked. The launcher→TUI initial-skill slot is reusable by any future guided-session command; the TUI's contract is "invoke this named skill once at startup," and freshness/resume policy stays with the launcher that owns session identity. The [TUI skill slash command](2026-07-21-tui-skill-slash-command.md) remains the mechanism; this note adds a launcher-driven auto-invocation of it and does not supersede it. diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md new file mode 100644 index 0000000000..a9a8a70212 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md @@ -0,0 +1,43 @@ +# Agent Note:`dsh migrate`/`dsh upgrade` 以 skill 播种首轮 + +Status: implemented + +[English](2026-07-28-dsh-guided-skill-session-commands.md) | 中文 + +## 问题 + +有两个反复出现的流程都以用户手动调用某个 skill 并回答其问题开始:从其他编码 agent 迁移,以及升级本 checkout。二者都要求用户知道该 skill 存在,并把 `/skill:dsh-migrate` 或 `/skill:dsh-upgrade` 作为会话首轮键入。一个专用入口命令若能让用户直接进入该引导式会话,便可省去这一发现步骤。 + +## 决策 + +`dsh migrate` 与 `dsh upgrade` 以全新会话启动普通 TUI,其首轮自动调用一个内置 skill(`dsh-migrate`、`dsh-upgrade`),效果等同于用户键入 `/skill:` 并回车。 + +播种复用现有的 TUI skill 路径,而非新增一条。`createTuiChat` 已有 `invokeSkill(name, instructions)`——即键入 `/skill:` 所走的代码,包含“未知 skill”通知。启动器通过一个新的启动上下文槽 `INITIAL_SKILL_KEY`(`tuiInitialSkill`)把 skill 名称传给应用,与 `MAIN_SESSION_ID_KEY`/`TUI_GOODBYE_MESSAGE_KEY` 一致:`ctx.provide` 是从启动器 argv 进入 Loader 挂载插件的唯一通道。TUI 的 `apply()` 读取该槽并折叠进 `config.initialSkill`;`ui.start()` 成功后,`createTuiChat` 在其被设置时调用一次 `invokeSkill(config.initialSkill, '')`。 + +**新鲜性在启动器而非 TUI 中把关。** `runSkillSession` 总是创建全新会话,且仅在 `resumeSessionId === undefined` 时提供该槽,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。TUI 保持通用:它只是把接到的 skill 在启动时调用一次。 + +**`migrate`/`upgrade` 不接受任何选项。** 与 `meta` 不同,它们不带 `--resume`、`--config` 或 `-p`;引导式全新会话入口没有可恢复或可重配置的内容。任何泄漏的默认界面选项都会明确报错,与 Commander 适配器中 `web`/`meta` 的拒绝模式一致。两个 mode 共用一个 `SkillSessionInvocation` 判别式(`mode: 'migrate' | 'upgrade'`);`bin.ts` 将 mode 映射为 `dsh-${mode}`。 + +`dsh-migrate` skill 内置于 `skills/`(经 `DSH_BUNDLED_SKILL_DIR` 交付,与 `dsh-upgrade` 相同)。若未说明源 agent,它会先询问是哪个(opencode/pi/Claude Code/Codex),再把每项能力——workspace 指令、个人覆盖、skills、hooks、MCP、API/env——映射到对应的 DSH 等价物,并基于仓库实际的表面(`hooks-claude`/`hooks-codex` 桥、`~/.dsh/{config.yaml,.env,AGENTS.md,skills/}`、`AGENTS.md`/`CLAUDE.md`、`mcporter`)落地;当某能力无等价物时明确说明。 + +## 测试 + +`apps/cli/tests/args.spec.ts` 新增 `migrate`/`upgrade` 的路由(裸判别式),以及每个子命令两侧任一泄漏选项的退出码 1。 + +`packages/ui/tui/tests/tui.spec.ts` 在既有 skill describe 块中新增两个伪终端用例:设置 `config.initialSkill` 时无需用户输入即把渲染后的 skill 正文作为首轮投递;未知的初始 skill 以通知形式报告且不发送。`runSkillSession` 本身是模块 `v8 ignore` 块内的组装,与 `runTui`/`runMeta` 相同。 + +无 keyless PTY 快照:依据维护者对本次改动的范围裁定,单元覆盖加交互式验证已足够,且播种走的是已有快照的 `/skill:` 渲染路径。两个命令均已在 tmux 中从临时 cwd 交互式验证:`dsh migrate` 加载 `dsh-migrate` 并询问源 agent;`dsh upgrade` 加载 `dsh-upgrade`,后者引入 `dsh-customize` 并开始 checkout 发现。 + +## 考虑过的替代方案 + +**预填输入框并让用户按回车。** 已否决:需要新增编辑器预填 seam,且仍需一次按键。自动提交复用 `invokeSkill`,实现预期的一命令入口。 + +**播种自然语言指令(“使用 dsh-migrate skill……”)而非 `/skill:`。** 在此否决:字面 skill 调用路径会确定性地把 skill 正文渲染进首轮,与手动命令完全一致,而不依赖模型自行选择加载该 skill。 + +**在 `migrate`/`upgrade` 上支持 `--resume`。** 已否决:它们是一次性引导入口。恢复的会话是可经默认界面 `dsh --resume ` 到达的普通 TUI 会话;恢复时重新注入 skill 会重复首轮。 + +**在应用 bundle 中读取 `INITIAL_SKILL_KEY`(像 `MAIN_SESSION_ID_KEY` 那样)而非在 TUI 的 `apply()` 中。** 无此必要:`initialSkill` 是在 `createTuiChat` 中消费的 TUI `Config` 字段,因此在 TUI 入口处把该槽折叠进 config,可与其他启动器拥有的运行时读取(`tuiResumeHost`、`tuiGoodbyeMessage`)并列,且无需改动应用 bundle。 + +## 后果 + +迁移或升级从任何位置都只需一条命令,且引导 skill 已被调用。启动器→TUI 的初始 skill 槽可被未来任何引导式会话命令复用;TUI 的契约是“在启动时调用一次这个具名 skill”,而新鲜性/恢复策略留在拥有会话身份的启动器一侧。[TUI skill 斜杠命令](2026-07-21-tui-skill-slash-command.md)仍是该机制;本 note 在其之上新增了一个由启动器驱动的自动调用,并未取代它。 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml new file mode 100644 index 0000000000..1a7c941216 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md +2026-07-28-dsh-meta-source-workspace.md: d65e0e6ff092b63931dd58c52fa76fb76a071dff +2026-07-28-dsh-meta-source-workspace.zh.md: 72a0c65e6eeda7d63dccc2306a3d7625cdb54362 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md new file mode 100644 index 0000000000..d65e0e6ff0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md @@ -0,0 +1,49 @@ +# Agent Note: `dsh meta` boots the TUI over the harness checkout + +Status: implemented + +English | [中文](2026-07-28-dsh-meta-source-workspace.zh.md) + +## Problem + +`dsh` treats the invoking directory as the workspace, which is what makes it useful on arbitrary projects. Working on dsh itself therefore means `cd`-ing to the checkout first — and the checkout is not a memorable path: the source install keeps it under a container directory as a timestamped staging worktree (`~/.dsh/source/staging-`) behind a `current` symlink, so the target moves on every upgrade. The agent is already *told* where its source lives by the `harness:source` prompt section, and the `cordis` toolset can modify that runtime, but the human still had to locate the directory by hand to start a session there. + +## Decision + +`dsh meta` boots the ordinary TUI with the harness checkout as the workspace, from any directory. + +The target is `SOURCE_ROOT` in `apps/cli/src/tui.ts` — `fileURLToPath(new URL('../../..', import.meta.url))`, three hops up from `apps/cli/{src,lib}` — the same constant the `harness:source` prompt section already names, so the workspace and the path advertised to the model cannot drift. It follows the launcher's real path, so a PATH symlink through `current` resolves to whichever staging worktree is active. + +The mechanism is one `process.chdir(workspace)` inside `runTui`, guarded by a new optional third parameter that only `runMeta` passes. The cwd *is* the workspace seam in the shipped tree: `examples/tui-agent/cordis.yml` derives the session cwd (`!!js process.cwd()`), the `./.sessions` persistence root, and the HMR watch root (`root: ['.']`) from it, so one chdir moves all three together and meta sessions land in the checkout's gitignored `.sessions/`. It runs after both `.env` layers are loaded — the bin's invoking-directory load and the personal one — so the ambient > project > personal precedence is untouched. `DEFAULT_CONFIG` and `SOURCE_ROOT` are absolute and TUI mode passes no snapshot mode, so config resolution is chdir-independent. + +`meta` accepts only `--resume `. `--config` would boot a foreign tree against the harness workspace, which is the `--config` case rather than this one; `-p` is not interactive. Both fail loud, as does an empty `--resume=` — matching the default surface, where a swallowed empty id would silently start a fresh session. + +**`meta` does not redeclare `--resume`.** Commander parses an option a subcommand shares with its parent into `program.opts()` and leaves the subcommand's own options object empty, so redeclaring it silently dropped the id (found by probing the adapter, not by review). The action reads `program.opts()`, which also accepts the flag on either side of the subcommand; `--help` still lists it among the parent's options. + +## Testing + +`apps/cli/tests/args.spec.ts` extends its two existing cases rather than adding a file: routing for `meta`, `meta --resume `, and `--resume meta` (pinning the shared-option behavior above), and exit-1 for `meta --resume=`, `meta --config`, and `meta -p`. `runMeta` itself is composition inside the module's existing `v8 ignore` block, like `runTui`. + +There is no keyless PTY smoke for this mode. The smoke harness gives each run a temp cwd, but `dsh meta` deliberately chdirs to the real checkout, so a smoke would write `.sessions/` into the live tree mid-test. Covering it properly needs an injectable target directory — a test-only seam this note declines to add for a one-line chdir. + +The mode was verified interactively instead. Launched from `$HOME`, a `pwd` tool call reports the checkout, git resolves to its branch, the session log lands under the checkout's `.sessions/` (leaving `~/.sessions` untouched and the tree free of unignored residue), and plain `dsh` from another directory still uses the invoking one. + +`dsh meta --resume ` once started a *fresh* session instead of resuming — a pre-existing defect on the default surface, not one this mode introduced. [Launcher-owned resume identity](../architecture/2026-07-28-launcher-owned-resume-identity.md) found the cause and fixed it: a personal overlay had replaced the whole `tui-agent` config block, overwriting the shipped `resumeSessionId` intake with a read of an unset environment variable, so a valid id was silently ignored. Session identity is now a launcher-owned context slot that no config key can displace, and `meta` routes through it. + +## Alternatives considered + +**Thread an explicit workspace through `boot` and the config tree.** Avoids mutating process-wide state, but the shipped config reads the cwd in three places (`!!js process.cwd()`, `persistenceRoot`, HMR `root`), so each would need its own new plumbing and config key to stay consistent. `chdir` before boot expresses "this is the workspace" once, at the seam that already means it. + +**A `--meta` flag on the default surface.** Rejected: the default surface is option-only so that subcommands do not collide with a positional, and a flag that silently relocates the workspace reads as a modifier of the current directory rather than a different target. `meta` alongside `web` matches the existing shape. + +**Resolve `~/.dsh/source/current` instead of the launcher's own path.** Rejected: it would diverge from the `harness:source` prompt path whenever a non-installed checkout's `bin/dsh` is invoked directly, telling the model one source root while working in another. + +**Make the printed resume hint mode-aware.** Deferred here as a known cost, then delivered by [launcher-owned resume identity](../architecture/2026-07-28-launcher-owned-resume-identity.md): the exit line became a launcher-provided context slot, so meta mode prints `dsh meta --resume ` and a copied hint works from any directory. It previously came from static config as `dsh --resume {session}` and only worked when re-run from the checkout. + +## Consequences + +Starting a session on dsh's own source is `dsh meta` from anywhere, and the workspace is guaranteed to be the same checkout the model is told about. Meta sessions are isolated in the checkout's `.sessions/`, so `dsh meta --resume` sees only other meta sessions — intended, since a session's logged cwd belongs to its workspace. + +The resume hint was this mode's original cost and is now resolved. [Launcher-owned resume identity](../architecture/2026-07-28-launcher-owned-resume-identity.md) made both the printed line and the in-place `/resume` handoff reproduce the mode as `dsh meta --resume ` from one shared argv helper, so a copied hint works from any directory and the handoff no longer depends implicitly on `execve` preserving the process cwd. + +`runTui` gains an optional third parameter, so the workspace override is visible at the one function that owns TUI composition rather than hidden in a second copy of it. diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md new file mode 100644 index 0000000000..72a0c65e6e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md @@ -0,0 +1,49 @@ +# Agent Note:`dsh meta` 以 harness 检出为 workspace 启动 TUI + +Status: implemented + +[English](2026-07-28-dsh-meta-source-workspace.md) | 中文 + +## Problem + +`dsh` 把调用目录视为 workspace,这正是它能作用于任意项目的原因。但因此,开发 dsh 自身就得先 `cd` 到检出目录——而该目录并不是一个好记的路径:源码安装会把它放在一个容器目录下、作为带时间戳的 staging 工作树(`~/.dsh/source/staging-`),并由 `current` 符号链接指向,因此每次升级后目标都会变化。`harness:source` 提示词段已经*告知* agent 其源码位置,`cordis` 工具集也能修改该运行时,但人类仍需手工定位该目录才能在其中开始会话。 + +## Decision + +`dsh meta` 在任意目录下都以 harness 检出为 workspace 启动普通 TUI。 + +目标是 `apps/cli/src/tui.ts` 中的 `SOURCE_ROOT`——`fileURLToPath(new URL('../../..', import.meta.url))`,从 `apps/cli/{src,lib}` 向上三级——与 `harness:source` 提示词段所用的常量完全相同,因此 workspace 与告知模型的路径不可能发生偏离。它跟随启动器的真实路径,所以经由 `current` 的 PATH 符号链接会解析到当前生效的那个 staging 工作树。 + +机制是 `runTui` 内的一次 `process.chdir(workspace)`,由一个新的可选第三参数把守,只有 `runMeta` 会传入。在已交付的配置树中,cwd *就是* workspace 的接缝:`examples/tui-agent/cordis.yml` 由它派生出会话 cwd(`!!js process.cwd()`)、`./.sessions` 持久化根目录以及 HMR 监视根目录(`root: ['.']`),因此一次 chdir 会让三者一并移动,meta 会话则落在检出目录中被 gitignore 的 `.sessions/` 内。它在两层 `.env` 都加载之后执行——bin 对调用目录的加载与个人层加载——因此“环境中已有的值 > 项目 > 个人”的优先级不受影响。`DEFAULT_CONFIG` 与 `SOURCE_ROOT` 都是绝对路径,且 TUI 模式不传 snapshot mode,所以配置解析与 chdir 无关。 + +`meta` 只接受 `--resume `。`--config` 会以 harness workspace 启动其他配置树,那属于 `--config` 的场景而非本场景;`-p` 并非交互式。两者都会明确报错,空的 `--resume=` 亦然——与默认界面一致,在那里被吞掉的空 id 会静默开启一个新会话。 + +**`meta` 不重新声明 `--resume`。** 对于子命令与父命令共享的选项,Commander 会将其解析进 `program.opts()`,而把子命令自身的 options 对象留空;因此重新声明会静默丢弃该 id(这是通过实测适配器发现的,而非评审发现)。action 读取 `program.opts()`,这同时也允许该标志出现在子命令的任意一侧;`--help` 仍会在父命令的选项中列出它。 + +## Testing + +`apps/cli/tests/args.spec.ts` 扩展其已有的两个用例而非新增文件:`meta`、`meta --resume ` 与 `--resume meta` 的路由(钉住上述共享选项行为),以及 `meta --resume=`、`meta --config`、`meta -p` 的退出码 1。`runMeta` 自身与 `runTui` 一样,属于该模块既有 `v8 ignore` 块内的组合代码。 + +该 mode 没有 keyless PTY 冒烟测试。冒烟框架会为每次运行提供临时 cwd,但 `dsh meta` 刻意 chdir 到真实检出目录,因此冒烟测试会在测试中途把 `.sessions/` 写入实际工作树。要正确覆盖它需要一个可注入的目标目录——为了一行 chdir 而引入的测试专用 seam,本 note 不予采纳。 + +取而代之的是交互式验证。从 `$HOME` 启动后,`pwd` 工具调用报告的是该检出目录,git 解析到其分支,会话日志落在该检出的 `.sessions/` 下(`~/.sessions` 未被触及,工作树也没有未被忽略的残留),并且从其他目录运行的普通 `dsh` 仍使用调用目录。 + +`dsh meta --resume <有效 id>` 曾经开启一个*新*会话而非恢复——这是默认界面上既已存在的缺陷,并非本 mode 引入。[由启动器持有的会话身份与退出行](../architecture/2026-07-28-launcher-owned-resume-identity.md) 查明了原因并将其修复:一个个人 overlay 替换了整个 `tui-agent` 配置块,用对一个未设置的环境变量的读取覆盖了已交付的 `resumeSessionId` 入口,因此有效的 id 会被静默忽略。会话标识如今是一个启动器拥有的上下文槽位,没有任何配置键能取代它,而 `meta` 经由它进行路由。 + +## Alternatives considered + +**通过 `boot` 与配置树显式传递 workspace。** 这可避免修改进程级状态,但已交付的配置在三处读取 cwd(`!!js process.cwd()`、`persistenceRoot`、HMR `root`),每一处都需要各自新增管线与配置键才能保持一致。启动前 chdir 只在本就表达该含义的接缝上表达一次“这就是 workspace”。 + +**在默认界面上加一个 `--meta` 标志。** 拒绝:默认界面是纯选项形式,以免子命令与位置参数冲突;而一个会静默改变 workspace 的标志读起来像是对当前目录的修饰,而非另一个目标。`meta` 与 `web` 并列符合既有形态。 + +**解析 `~/.dsh/source/current` 而非启动器自身路径。** 拒绝:当直接调用某个非安装检出的 `bin/dsh` 时,它会与 `harness:source` 提示词路径产生偏离——告知模型一个源码根目录,却在另一个目录中工作。 + +**让打印的恢复提示随 mode 变化。** 在此作为已知代价推迟,随后由 [由启动器持有的会话身份与退出行](../architecture/2026-07-28-launcher-owned-resume-identity.md) 交付:退出行变成了一个启动器提供的上下文槽位,因此 meta 模式打印 `dsh meta --resume `,被复制的提示在任意目录下都有效。它此前来自静态配置,固定为 `dsh --resume {session}`,且只有在检出目录中重新运行才有效。 + +## Consequences + +在 dsh 自身源码上开启会话变成了在任意位置执行 `dsh meta`,且该 workspace 必然就是告知模型的那个检出目录。meta 会话被隔离在检出目录的 `.sessions/` 内,因此 `dsh meta --resume` 只能看到其他 meta 会话——这是预期行为,因为会话记录的 cwd 属于它的 workspace。 + +恢复提示曾是本 mode 的原初代价,如今已解决。[由启动器持有的会话身份与退出行](../architecture/2026-07-28-launcher-owned-resume-identity.md) 让打印的行与原地 `/resume` 移交都从同一个共享的 argv 辅助函数将该 mode 复现为 `dsh meta --resume `,因此被复制的提示在任意目录下都有效,且移交不再隐式依赖于 `execve` 保留进程 cwd。 + +`runTui` 新增一个可选第三参数,因此 workspace 覆盖是在拥有 TUI 组合逻辑的那唯一一个函数上可见的,而不是隐藏在它的第二份副本中。 diff --git a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.i18n.yaml new file mode 100644 index 0000000000..eb4893717e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md +2026-07-28-live-session-registry-and-dsh-ls.md: 02343c83ccee7b67e3b3e4c72de842415d4a9f6e +2026-07-28-live-session-registry-and-dsh-ls.zh.md: 722ac45f2eb07256f196d2828b8969ae9f4965b8 diff --git a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md new file mode 100644 index 0000000000..02343c83cc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md @@ -0,0 +1,65 @@ +# Agent Note: live-session registry and `dsh list-sessions` + +Status: implemented + +English | [中文](2026-07-28-live-session-registry-and-dsh-ls.zh.md) + +## Problem + +Nothing could answer "which dsh sessions am I running right now". A user with sessions across several projects had no way to enumerate them, and no way to recover the id needed for `--resume` except the exit line of the terminal that printed it. Session persistence records every session that ever existed, so it cannot answer the question: it has no notion of liveness, and no `process.pid` appeared anywhere in the session, persistence, or storage packages. + +## Decision + +`dsh list-sessions` (alias `dsh ps`) lists the sessions running right now — session id, pid, uptime, workspace, title — newest first, across every workspace, with `--json` for machines. Three packages back it, as a capability seam. + +[`dsh-session-registry`](../../../../packages/session-registry/session-registry/README.md) (`ctx.sessionRegistry`) is the seam: the abstract service contract and record vocabulary, so the medium can later move to a database without touching consumers. [`dsh-session-registry-file`](../../../../packages/session-registry/session-registry-file/README.md) implements it over one lock-guarded JSON file under the Harness home. [`dsh-session-registry-live`](../../../../packages/session-registry/session-registry-live/README.md) follows `session/created`, `session/disposed`, and `session/title` and keeps the registry in step. `apps/cli` mounts both on every launcher surface — the TUI, `dsh meta`, headless, and web — and `dsh list-sessions` mounts only the service, booting no agent tree. No surface label is recorded: a launcher's mode is not a property of the session, and the workspace column already distinguishes a `dsh meta` session from a project one. + +### Liveness is derived, never stored + +`list()` probes each record's pid with `kill(pid, 0)` and drops the dead ones, writing the pruned result back. A process killed without running its disposer leaves a record that the next read removes, so there is no daemon, no heartbeat, and no permanent phantom. A per-process `bootId` distinguishes a recycled pid, so deregistration cannot delete a namesake record from a different incarnation. `EPERM` counts as alive: a live session owned by another user must not be dropped. + +### Two independent concurrency layers + +The file is written by every dsh process and by several sessions inside one process, and the two cases need different mechanisms. + +Across processes, each read-modify-write holds a [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) advisory lock. Within one process, calls queue on an internal chain, because the advisory lock is tracked per process: overlapping same-process callers contend for its bounded retry budget rather than queueing, and past roughly a dozen concurrent calls that budget runs out and a registration rejects. Since publication is fire-and-forget, such a rejection silently drops a live session from the listing — the exact "listing that lies" failure this feature exists to avoid. Both layers are load-bearing and each is pinned by a test that fails without it. + +### Records carry their own title + +The title is the one mutable field, replaced through `retitle` as `session/title` events arrive. It lives in the record rather than being read from the session log because the log's location, format, and compression are per-deployment backend choices: the TUI writes project-local zstd-compressed JSONL, the web and headless surfaces write to a global root, a user profile overrides either, and SQLite has no per-session file at all. An independent reader cannot portably parse that, so `dsh list-sessions` opens no log and assumes no backend. + +### Subagents are invisible by construction + +Only top-level launcher surfaces mount the publisher. In-process subagents (`spawn`, `fork`) have no process of their own, and the out-of-process backends spawn `dsh-jsonrpc-agent` rather than this CLI. No filter flag is needed, and no subagent package changed. + +## Alternatives considered + +**One file per session under `~/.dsh/run/`.** No lock at all, since each process only writes and deletes its own file. Rejected in favour of the single file the user chose, which then made a real advisory lock mandatory rather than optional. + +**A domain over the `storage-json` backend.** The obvious reuse, and wrong: that backend documents "no cross-process write locking … last write wins" and names single-host-process as its assumption, and the [domain KV storage note](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md) puts multi-process explicitly out of scope. A registry written concurrently by every launcher is precisely that excluded case. Widening the backend's contract would have changed a shipped guarantee for one consumer; a separate package owns the multi-process medium instead. + +**Hand-rolled `O_EXCL` lock directory.** Rejected under the [dependencies-over-hand-rolling policy](../process/2026-07-26-dependencies-over-hand-rolling.md): stale-lock detection, retry backoff, and compromise handling are exactly the surface a maintained dependency should own. + +**Accept last-write-wins on the single file.** Cheapest to build, and it silently omits real running sessions when two start close together. A listing tool that lies is worse than no listing tool. + +**Read the title from the session log in `dsh list-sessions`.** Implemented first, then verified live: the shipped TUI writes `session.jsonl.zstd`, whose frame helpers are internal to the jsonl backend. Exporting them would have hard-coded one backend's file format into the CLI and still shown nothing for SQLite. + +**Register the web server itself with a placeholder session id.** `dsh web` owns no session — its sessions are created later by browser clients — so a server row would have put a fake id in a session table. Following session lifecycle instead makes browser sessions appear and disappear as they are opened, which also subsumed the TUI's launcher-side registration and deleted that separate path. + +**A `--here`/`--workspace` filter.** Dropped on request: the listing is always global, and narrowing is the user's `grep`. + +## Consequences + +The registry is an observability aid, so every write is best-effort: a registry fault warns and never fails a working agent session. The cost is that a listing can lag reality by one failed write, healed by the next. + +Title mirroring costs one locked read-modify-write per revision, so an aggressive retitling cadence pays that write each time. + +`bootId` bounds pid reuse only for records this process wrote. A foreign record whose pid the operating system has reassigned to an unrelated live process is reported alive until its owner removes it — accepted because the portable alternative, reading real process start times, is `/proc`-only. + +Liveness is pid existence, not health: a hung process still lists as running. The registry deliberately makes no progress judgement. + +## Testing + +Unit coverage pins durable-format validation (torn text, foreign version, per-row damage that must not hide siblings), pid pruning against a genuinely reaped pid, `EPERM`-is-alive, incarnation-scoped deregistration, and `retitle` scoping. Both concurrency layers have a regression test verified to fail when its mechanism is removed: 8 real processes for the cross-process lock, 24 overlapping in-process calls for the chain. The publisher is tested over the real `SessionStore` rather than a hand-built emitter, because publication depends on the store's actual lifecycle dispatch. + +Verified live in tmux against the assembled application: two concurrent TUI sessions in different workspaces both listed, a title appeared after the first turn, clean exit deregistered, and `SIGKILL` left a stale record that the next `dsh list-sessions` pruned and durably rewrote. diff --git a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.zh.md b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.zh.md new file mode 100644 index 0000000000..722ac45f2e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.zh.md @@ -0,0 +1,65 @@ +# Agent Note: 活跃会话注册表与 `dsh list-sessions` + +Status: implemented + +[English](2026-07-28-live-session-registry-and-dsh-ls.md) | 中文 + +## 问题 + +没有任何东西能回答「我此刻正在运行哪些 dsh 会话」。会话散落在多个项目中的用户既无法枚举它们,也无法找回 `--resume` 所需的 id,唯一的来源是打印过它的那个终端的退出行。会话持久化记录了曾经存在过的每个会话,因此它答不了这个问题:它没有存活状态的概念,而且 session、persistence、storage 这几个包里任何位置都没有出现过 `process.pid`。 + +## 决策 + +`dsh list-sessions`(别名 `dsh ps`)列出此刻正在运行的会话(会话 id、pid、运行时长、工作区、标题),最新的排在最前,覆盖所有工作区,并提供面向机器的 `--json`。背后由三个包(package)以能力 seam 的形式支撑。 + +[`dsh-session-registry`](../../../../packages/session-registry/session-registry/README.md)(`ctx.sessionRegistry`)是 seam:抽象服务契约与记录词汇,使介质将来可以换成数据库而不触及消费方。[`dsh-session-registry-file`](../../../../packages/session-registry/session-registry-file/README.md) 在 Harness home 下的一个加锁保护的 JSON 文件上实现它。[`dsh-session-registry-live`](../../../../packages/session-registry/session-registry-live/README.md) 跟随 `session/created`、`session/disposed` 和 `session/title`,让注册表保持同步。`apps/cli` 在每个启动方接口(TUI、`dsh meta`、headless、web)上都挂载这两个包,而 `dsh list-sessions` 只挂载该服务,不启动任何 agent(智能体)树。不记录任何接口标签:启动方的模式并不是会话的属性,而工作区那一列已经能把 `dsh meta` 会话和项目会话区分开。 + +### 存活状态是推导出来的,绝不存储 + +`list()` 用 `kill(pid, 0)` 探测每条记录的 pid,剪除已消亡的记录,并把剪除后的结果写回。未运行 disposer(资源释放)就被杀掉的进程留下的记录,会被下一次读取移除,因此不需要 daemon,不需要心跳,也不会有永久残留的幽灵记录。每个进程独有的 `bootId` 用于区分被复用的 pid,因此注销不会删除属于另一个 incarnation 的同名记录。`EPERM` 算作存活:归属于另一个用户的存活会话绝不能被丢掉。 + +### 两层相互独立的并发机制 + +该文件既被每个 dsh 进程写入,也被同一进程内的多个会话写入,这两种情形需要不同的机制。 + +跨进程时,每次读-改-写都持有一个 [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) 咨询锁。进程内则由各次调用在内部链上排队,因为咨询锁是按进程跟踪的:同一进程中重叠的调用方会争抢它有界的重试预算,而不是排队等待;大约超过十几次并发调用后,该预算耗尽,某次注册就会被拒绝。由于发布采用 fire-and-forget 方式,这样一次拒绝会静默地把一个存活会话从列表中丢掉——而这正是本功能要避免的「列表说谎」故障。两层机制都是必需的,且各有一个测试固定它:移除该机制,对应测试就会失败。 + +### 记录自带标题 + +标题是唯一的可变字段,随 `session/title` 事件到达,通过 `retitle` 替换。它存放在记录里,而不是从会话日志读取,因为日志的位置、格式和压缩都是逐部署的后端选择:TUI 写入项目本地的 zstd 压缩 JSONL,web 与 headless 界面写入全局根目录,用户配置文件可以覆盖二者,而 SQLite 根本没有逐会话的文件。独立读取方无法以可移植的方式解析这些内容,因此 `dsh list-sessions` 不打开任何日志,也不假定任何后端。 + +### subagent 在设计上就不可见 + +只有顶层启动方接口才挂载发布方。进程内 subagent(`spawn`、`fork`)没有自己的进程,而进程外后端 spawn 的是 `dsh-jsonrpc-agent` 而不是本 CLI(命令行界面)。不需要任何过滤开关,也没有改动任何 subagent 包。 + +## 考虑过的替代方案 + +**在 `~/.dsh/run/` 下每个会话一个文件。** 完全不需要锁,因为每个进程只写入和删除自己的文件。不予采纳,改用用户选定的单文件方案,而这也使真正的咨询锁从可选变为必需。 + +**在 `storage-json` 后端之上做一个 domain。** 这是最显而易见的复用,但它是错的:该后端明确记载「无跨进程写锁……最后写入者胜出」,并把单一宿主进程列为自身前提,而[domain KV 存储 note](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)明确把多进程排除在范围之外。被每个启动方并发写入的注册表恰恰就是这个被排除的场景。放宽该后端的契约,等于为一个消费方改动一项已上线的保证;改由一个独立的包拥有这套多进程介质。 + +**手写 `O_EXCL` 锁目录。** 依据[优先使用依赖而非手写政策](../process/2026-07-26-dependencies-over-hand-rolling.md)不予采纳:陈旧锁检测、重试退避和受损处理,恰恰是应当由一个有人维护的依赖拥有的那部分工作。 + +**在单文件上接受最后写入者胜出。** 这是最省事的实现,但当两个会话相近时间启动时,它会静默漏掉真实运行中的会话。一个会说谎的列表工具比没有列表工具更糟。 + +**在 `dsh list-sessions` 中从会话日志读取标题。** 该方案先落地实现,随后经实机验证否决:上线的 TUI 写入 `session.jsonl.zstd`,其帧处理辅助函数是 jsonl 后端的内部实现。把它们导出,等于把某一个后端的文件格式硬编码进 CLI,而且对 SQLite 仍然什么都显示不出来。 + +**用占位会话 id 注册 web 服务器本身。** `dsh web` 不拥有任何会话(它的会话由浏览器客户端稍后创建),因此一行服务器记录会把一个假 id 放进会话表。改为跟随会话生命周期后,浏览器会话会随打开与关闭而出现和消失,这同时也涵盖了 TUI 启动方一侧的注册,并删除了那条独立路径。 + +**加一个 `--here`/`--workspace` 过滤开关。** 按要求放弃:列表始终是全局的,收窄范围交给用户自己的 `grep`。 + +## 后果 + +注册表是一项可观测性辅助设施,因此每次写入都是尽力而为:注册表故障只发出警告,绝不让正常工作的 agent 会话失败。代价是列表可能因一次失败的写入而落后于现实一步,并由下一次写入修复。 + +标题镜像每次修订都要付出一次加锁的读-改-写,因此改名节奏激进时,每次改名都要付出这一次写入。 + +`bootId` 只对本进程写入的记录约束 pid 复用。如果一条外来记录的 pid 已被操作系统重新分配给一个无关的存活进程,那么在其所有者移除它之前,该记录会被报告为存活——之所以接受,是因为可移植的替代方案(读取进程真实启动时间)仅在 `/proc` 上可用。 + +存活状态只表示 pid 存在,不表示健康:挂死的进程仍会被列为正在运行。注册表刻意不对进展作出判断。 + +## 测试 + +单元覆盖固定了持久格式校验(截断文本、外来版本、不得遮蔽同级记录的单条损坏)、针对真正已回收 pid 的剪除、`EPERM` 算存活、按 incarnation 限定范围的注销,以及 `retitle` 的作用范围。两层并发机制各有一个回归测试,且都已验证在移除对应机制后会失败:跨进程锁用 8 个真实进程,进程内链用 24 次重叠调用。发布方在真实的 `SessionStore` 上测试,而非手搭的事件发射器,因为发布依赖该 store 实际的生命周期派发。 + +已在 tmux 中针对组装后的应用实机验证:位于不同工作区的两个并发 TUI 会话都被列出,第一轮之后出现标题,正常退出完成注销,而 `SIGKILL` 留下的陈旧记录被下一次 `dsh list-sessions` 剪除并持久重写。 diff --git a/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.i18n.yaml new file mode 100644 index 0000000000..91227d80e1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md +2026-07-28-source-guard-staging-edit-gate.md: 8452006190166c783efafc398566ef7f4da10323 +2026-07-28-source-guard-staging-edit-gate.zh.md: 83589ce833e4aa74968b40247848685a1e030f6b diff --git a/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md new file mode 100644 index 0000000000..8452006190 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md @@ -0,0 +1,76 @@ +# Agent Note: source-guard denies direct staging-checkout edits + +Status: implemented + +English | [中文](2026-07-28-source-guard-staging-edit-gate.zh.md) + +## Problem + +The [`dsh-customize`](../../../../skills/dsh-customize/SKILL.md) skill governs every personal change to a dsh source checkout: implement in a task worktree branched from the staging tip, then integrate under `.agents/merge.lock`. Its central rule is negative — do not edit the personal staging checkout directly — and a negative rule delivered only as prompt text fails in exactly the case that matters. An agent that never loads the skill never sees the rule, and one that loads it early can still forget it thirty tool calls later. The failure is silent and expensive: commits land on the staging branch that the launcher runs from, outside any task branch, with no lock held and no rollback worktree. + +Prompt guidance cannot fix this, because the guidance is what went unread. The rule needs an enforcement point. + +## Decision + +`@deepseek-ai/dsh-source-guard` (`packages/guard/source-guard/`) is a `tools/pre-execute` listener that returns `{kind: 'deny', reason}` for a `write` or `edit` whose target resolves inside a protected staging worktree, unless the calling session's durable log already records a successful `skill` call naming `dsh-customize`. It registers no service and contributes no prompt text or tool schema; an allowed call is indistinguishable from one made without the plugin. It is not in any shipped default composition. + +### Git identity from files, not a path prefix and not `git` + +Whether a path is protected is decided by reading `.git`, its `gitdir:` pointer, and `HEAD`. Three shapes resolve: a plain clone (`.git` is a directory that is its own common dir), a linked worktree (`.git` is a file pointing at `/worktrees/`, whose common dir is two levels up), and a detached HEAD (`HEAD` holds a raw object id and names no branch). A `gitdir:` pointer resolves whether absolute — what `git worktree add` writes — or relative, which git resolves against the worktree directory holding it. + +Denial requires the target's worktree to match the launcher's on both identities: the same shared git directory and the same branch. Both come from resolving `protectedCheckout`, so nothing about the protected branch is configured. An earlier revision matched a `dsh-staging/*` name pattern instead; the exact-branch rule replaced it because a pattern is wrong in both directions. It denied every sibling staging worktree an old install had left behind, none of which runs a launcher, and it silently protected nothing for a maintainer whose staging branch follows no naming convention — a fatal property for a shipped default that must hold for checkouts [`scripts/install.sh`](../../../../scripts/install.sh) did not create. + +A path-prefix rule would have been wrong, not merely imprecise. The task worktrees the skill prescribes live *inside* the protected tree at `/.worktrees/...`, so a prefix rule would deny every edit the workflow requires. Resolution walks outward from the target and stops at the first enclosing worktree, so it reports the innermost one: a nested task worktree answers with its own task branch and is allowed, while the launcher's own tree answers with the launcher's branch and is denied. + +Two path details decide whether the gate holds at all, and both are enforcement, not polish. Repository identity is compared on symlink-resolved paths (`canonicalPath` from `dsh-sandbox`), because a session cwd under `/var/...` and a configured path under `/private/var/...` are the same macOS directory and a lexical comparison would fail open on every write. And a relative `file_path` is resolved against the calling session's workspace, exactly as `dsh-tool-fs` resolves it; judging only absolute paths would have left a relative path as an unguarded route to a protected file. + +`protectedCheckout` names a path inside the guarded checkout, defaulting to this module's own file. That resolves the checkout the running harness was launched from — the live deployment, whatever its branch is named. A harness running from an installed copy resolves a different repository, or none, and guards nothing; the rule is meaningless outside a source checkout. + +The shipped TUI composition loads the plugin with these defaults, so every source install is protected without configuration. It is inert for an ordinary project: a workspace in another repository, or none, never matches the launcher's identities. + +### Satisfaction replayed from the durable log + +The gate lifts on a `tool/call` naming the `skill` tool whose arguments parse to `{name: }`, paired by call id with a non-error `tool/result`. Both fields are already durable (`packages/core/session/src/types.ts`), so this needs no new session event and no coupling to skill-provider internals. + +The log is the only state. In-memory satisfaction (the `WeakMap` shape [`repeat-tool-guard`](../../archived/feature/2026-07-08-repeat-tool-guard.md) uses for its chains) would be smaller, but it loses satisfaction on resume: a resumed session that already read the skill would be told to read it again, and the denial would look like a bug rather than a rule. Replay costs a scan bounded by the first hit and buys resume correctness. + +### Fail open, deliberately + +A path outside any worktree, a detached HEAD, a foreign repository, a malformed `gitdir:` pointer, and unreadable metadata all leave the call to the rest of the chain. The alternative — denying whenever git identity is unavailable — converts any `.git` permission problem into a harness that cannot write files at all. The guard exists to prevent one specific, recoverable mistake; it must not become a larger outage than the mistake. + +### Narrow scope + +`read` is never gated: inspecting staging violates nothing, and the skill explicitly permits read-only questions. `bash` is not gated either. Reliably classifying mutating shell commands is a matcher problem with no honest completion condition, so a determined model can still change staging through a shell. This is a boundary against forgetting, not a sandbox against intent. + +## Alternatives considered + +- **Advisory reminder instead of denial** (`additionalContexts` on `tools/post-execute`, the `repeat-tool-guard` shape). Rejected: the write has already happened when the reminder arrives, so the violation is committed and the guidance is again just text. +- **`{kind: 'ask'}` routed to approval.** Rejected: it prompts on every legitimate task-worktree edit in the common case, and degrades to denial in a composition without approval support, making behavior depend on unrelated plugins. +- **Running `git rev-parse` through `ctx.subprocess`.** Rejected after measuring the alternative: two file reads answer the same question with no process spawn per gated write, no `git` on `PATH` requirement, and no subprocess dependency. Reading `.git` and `HEAD` is a stable on-disk format, not an implementation detail. +- **Explicit `protectedRoots` config with no detection.** Rejected: it makes the common case require configuration to be correct, and a stale absolute path silently disables protection. +- **A configurable staging-branch name pattern** (`stagingBranchPatterns`, default `dsh-staging/*`). Shipped first, then removed: it protects the wrong set in both directions — every stale sibling worktree that runs no launcher, and nothing at all for a maintainer whose branch is named otherwise. Deriving the branch from the launcher needs no configuration and cannot be misconfigured. +- **Auto-detecting the checkout with no override.** Rejected: the detection is a default, not a law; a deployment guarding a different checkout, or running from an installed copy, needs the explicit value. +- **Denying everything under the checkout root, `.worktrees/` included.** Rejected: it blocks the workflow the skill prescribes, so the guard would fire on every legitimate task edit. +- **Gating `bash` with a mutating-command matcher.** Deferred, not rejected: worth revisiting if bypasses are observed in practice. A matcher that is wrong in either direction is worse than an honestly narrow gate. + +## Consequences + +The rule now holds without depending on the model having read it, and the denial names the path, the branch, and the skill, so the model's next action is determined rather than guessed. Enforcement sits at the operation boundary that owns the decision, so it cannot be bypassed by prompt filtering or listener order. + +Shipping it in the TUI default means every source install is protected without configuration, and the protection follows the launcher across upgrades because the branch is derived rather than named. The cost of that reach is that the plugin loads for every user, including those whose workspace it can never match. + +What it cost otherwise: the guard is only as complete as its tool list, and `bash` remains open. Worktree identity is cached per directory for the plugin's lifetime, so a mid-session branch switch is not observed on either side. Only the launcher's own checkout is protected, so a stale sibling stays editable. Loading the skill lifts the gate for the whole session without verifying the workflow was actually followed — the gate proves the instructions were read, not obeyed. Satisfaction is per session, so a subagent with its own session must load the skill itself. + +## Testing + +Unit suites drive a real agent loop against a mock adapter over real git-metadata fixtures — a staging worktree, a task worktree nested inside it, a plain clone, a foreign repository on a staging-named branch, a detached HEAD, absolute and relative `gitdir:` pointers, a symlinked route to one repository, a malformed pointer, and unreadable metadata — covering both source files to per-file 100%. A companion `invariant.ts` validates the durable denial's shape, since the refusal text is the package's only model-visible output and is actionable only when it names the path, branch, and skill. + +The real-composition smoke boots `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml` through the Loader and the headless app, and asserts three things about the assembled run: the tool result is an error, its text is the exact denial, and the targeted file still holds its original bytes — enforcement before dispatch, not advice after it. + +An ACP snapshot scenario (`source-guard-staging-deny`) originally owned the assembled transcript, seeding a staging worktree in the harness's generated cwd through a new `Scenario.prepareCwd` hook — git never tracks an entry named `.git` and `.gitignore` excludes every `worktrees/` directory, so the fixture committed the two `HEAD` bodies and the hook assembled the real layout. Authoring it paid for itself immediately: it exposed both path defects above (the transcript showed `fs-policy` answering first wherever the guard had quietly declined to judge) and then caught its own first fixture, whose ignored `worktrees/` path passed locally from an untracked file. The scenario was later removed with the assembled-run evidence consolidated into the Loader-composition smoke; the `prepareCwd` hook it introduced remains part of the snapshot harness for repository-shaped fixtures. + +## Related + +- [The personal-staging maintenance skills Agent Note](../process/2026-07-23-personal-staging-maintenance-skills.md) — the workflow this gate enforces one rule of. That note owns the skills' content and discovery; this one owns the enforcement point and holds no authority over the workflow itself. +- [The interception-seams Agent Note](2026-06-30-interception-seams.md) — the `tools/pre-execute` `allow`/`deny`/`ask` vocabulary this gate's denial uses. +- [The repeat-tool-guard Agent Note](../../archived/feature/2026-07-08-repeat-tool-guard.md) — the sibling guard whose advisory shape this one deliberately does not take. diff --git a/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.zh.md b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.zh.md new file mode 100644 index 0000000000..83589ce833 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.zh.md @@ -0,0 +1,76 @@ +# Agent Note: source-guard 拒绝直接编辑 staging 检出目录 + +Status: implemented + +[English](2026-07-28-source-guard-staging-edit-gate.md) | 中文 + +## Problem + +[`dsh-customize`](../../../../skills/dsh-customize/SKILL.md) skill(技能)规范对 dsh 源码检出的每项个人变更:先在从 staging 分支顶端分出的任务 worktree 中实现,再于 `.agents/merge.lock` 保护下完成集成。它的核心规则是一条禁令:不得直接编辑个人 staging 检出目录;而若只通过提示词文本传达禁令,它恰好会在最要紧的场景中失效。从未加载该 skill 的 agent(智能体)根本看不到规则;即便尽早加载,也仍可能在三十次工具调用后将其忘掉。这种失败既静默又代价高昂:提交会落在启动器实际运行的 staging 分支上,不属于任何任务分支,既未持有锁,也没有用于回滚的 worktree。 + +提示词指导无法解决这个问题,因为未被阅读的正是这些指导。该规则需要一个强制执行点。 + +## Decision + +`@deepseek-ai/dsh-source-guard`(`packages/guard/source-guard/`)是一个 `tools/pre-execute` 监听器;它会返回 `{kind: 'deny', reason}`,拒绝目标解析到受保护 staging worktree 内的 `write` 或 `edit`,除非调用会话的持久日志已经记录过一次成功的 `skill` 调用,且名称为 `dsh-customize`。它不注册服务,也不贡献提示词文本或工具 schema;获准的调用与未加载该插件时的调用没有区别。任何已交付的默认组合都不包含它。 + +### 从文件而非路径前缀或 `git` 判定 Git 身份 + +系统通过读取 `.git`、其中的 `gitdir:` 指针以及 `HEAD` 来判断路径是否受保护。它可以解析三种形态:普通克隆(`.git` 是目录,且自身就是共享目录)、链接 worktree(`.git` 是文件,指向 `/worktrees/`,其共享目录位于上两级)以及 HEAD 分离状态(`HEAD` 保存原始对象 id,不指向任何分支)。`gitdir:` 指针无论是绝对路径(`git worktree add` 写入的形式)还是相对路径都可以解析;Git 会以包含该指针的 worktree 目录为基准解析相对路径。 + +只有目标的 worktree 在两项身份上都与启动器的 worktree 匹配时才会拒绝:共用同一个共享 Git 目录,且分支相同。这两项身份均通过解析 `protectedCheckout` 得出,因此无需配置受保护分支的任何信息。较早版本则匹配 `dsh-staging/*` 名称模式;现已改用确切分支规则,因为模式会在两个方向上出错。它会拒绝旧安装留下的每一个同级 staging worktree,尽管其中没有任何一个运行着启动器;对于 staging 分支不遵循任何命名约定的维护者,它又会静默地完全不提供保护——而已交付的默认配置必须在 [`scripts/install.sh`](../../../../scripts/install.sh) 未创建的检出目录上也能生效,这一属性是致命的。 + +路径前缀规则不仅不精确,而且本身就是错误的。该 skill 规定的任务 worktree 位于受保护树*内部*的 `/.worktrees/...`,因此前缀规则会拒绝工作流要求的每一次编辑。解析过程从目标向外逐层查找,遇到第一个所属 worktree 时停止,因此返回最内层的 worktree:嵌套的任务 worktree 会返回自身的任务分支并获准,而启动器自身所在的树会返回启动器的分支并被拒绝。 + +有两个路径细节决定门禁究竟能否生效,二者都是强制执行要求,而非细节润色。仓库身份会按解析符号链接后的路径进行比较(使用 `dsh-sandbox` 的 `canonicalPath`),因为位于 `/var/...` 下的会话 cwd 和位于 `/private/var/...` 下的配置路径在 macOS 上是同一个目录,若按路径字符串比较,每次写入都会故障放行(fail-open)。此外,相对 `file_path` 会完全按照 `dsh-tool-fs` 的方式,相对于调用会话的工作区解析;若只判断绝对路径,相对路径就会成为绕过门禁访问受保护文件的路径。 + +`protectedCheckout` 指定受保护检出目录内的一条路径,默认值为本模块自身的文件。由此解析出运行中 harness 的启动来源检出目录——当前运行的部署,无论其分支采用什么名称。若 harness 从已安装副本运行,解析出的会是另一个仓库或没有仓库,因此不会保护任何内容;该规则在源码检出之外没有意义。 + +已交付的 TUI 组合会以这些默认值加载插件,因此每个源码安装无需配置即可受到保护。对于普通项目,它不会生效:若工作区位于其他仓库中,或不存在工作区,就绝不会匹配启动器的身份。 + +### 从持久日志回放满足状态 + +如果日志中存在一条 `tool/call`,它调用名为 `skill` 的工具,参数可解析为 `{name: }`,且按调用 id 能配对到非错误的 `tool/result`,门禁即解除。二者都已持久化(`packages/core/session/src/types.ts`),因此无需新增会话事件,也不与 skill 提供方内部实现耦合。 + +日志是唯一状态。在内存中记录满足状态(`WeakMap` 结构,[`repeat-tool-guard`](../../archived/feature/2026-07-08-repeat-tool-guard.md) 将其用于调用链)所需实现会更小,但恢复后满足状态会丢失:一个已经读取过该 skill 的恢复会话会被要求再次读取,而这次拒绝看起来会像缺陷而不是规则。回放的代价是扫描日志,但首次命中即停止,并换来恢复行为正确。 + +### 刻意采用故障放行 + +目标路径不在任何 worktree 内、HEAD 分离、属于其他仓库、`gitdir:` 指针格式错误或元数据不可读时,调用都会交给调用链的其余部分处理。反过来,只要无法判定 Git 身份就拒绝,会让任何 `.git` 权限问题都导致 harness 完全无法写文件。该 guard 旨在防止一种特定且可恢复的错误,不得造成比该错误更严重的故障。 + +### 范围收窄 + +`read` 从不受门禁限制:检查 staging 不会违反任何规则,而且该 skill 明确允许只读提问。`bash` 同样不受门禁限制。要可靠判定哪些 shell 命令会修改状态,需要构造一个无法给出可信完备标准的匹配器,因此执意修改的模型仍可通过 shell 修改 staging。这是一道防止遗忘的边界,不是阻止刻意操作的沙箱。 + +## Alternatives considered + +- **用建议性提醒代替拒绝**(使用 `additionalContexts`,挂载在 `tools/post-execute` 上,采用 `repeat-tool-guard` 的形态)。不予采纳:提醒到达时写入已经发生,违规已成事实,而指导又一次沦为纯文本。 +- **将 `{kind: 'ask'}` 交给审批。** 不予采纳:在常见场景中,它会对任务 worktree 内每次合法编辑都发起询问;在没有审批支持的组合中还会退化为拒绝,使行为取决于无关插件。 +- **运行 `git rev-parse`,并通过 `ctx.subprocess` 执行。** 对替代方案进行实测后不予采纳:读取两个文件即可回答同一问题,每次受门禁限制的写入都无需 spawn 进程,不要求 `git` 存在于 `PATH` 中,也不依赖子进程。读取 `.git` 与 `HEAD` 所依据的是稳定的磁盘格式,而非实现细节。 +- **显式配置 `protectedRoots`,不做检测。** 不予采纳:这会让常见场景的保护效果依赖配置正确性,而陈旧的绝对路径会静默禁用保护。 +- **可配置的 staging 分支名称模式**(`stagingBranchPatterns`,默认 `dsh-staging/*`)。最初随产品交付,随后删除:它从两个方向划错了保护范围——既纳入每个不运行启动器的陈旧同级 worktree,又完全不保护分支另有名称的维护者。由启动器派生分支无需配置,也不可能配置错误。 +- **自动检测检出目录,不提供覆盖项。** 不予采纳:检测只是默认行为,而非不可更改的规定;若部署要保护另一个检出目录,或自身从已安装副本运行,就需要显式值。 +- **拒绝检出根目录下的一切操作,包括 `.worktrees/`。** 不予采纳:这会阻断该 skill 规定的工作流,让 guard 在每次合法任务编辑时触发。 +- **用修改类命令匹配器把守 `bash`。** 推迟而非否决:如果实际观察到绕过行为,值得重新考虑。任一方向判断错误的匹配器,都不如如实限定范围的门禁。 + +## Consequences + +如今,该规则无需依赖模型已经读过它也能生效;拒绝理由会列出路径、分支与 skill,让模型的下一步操作明确,无需猜测。强制执行位于拥有该决策的操作边界,因此提示词过滤或监听器顺序都无法绕过它。 + +将其纳入 TUI 默认组合意味着每个源码安装无需配置即可受到保护;由于分支是派生而非按名称指定,保护会在升级时跟随启动器。这种覆盖范围的代价是插件会为每位用户加载,包括工作区永远不可能匹配启动器身份的用户。 + +除此之外的代价是:guard 的完整程度受限于其工具列表,`bash` 仍保持开放。worktree 身份在插件生命周期内按目录缓存,因此无法观察到任一侧在会话中途切换分支。只保护启动器自身的检出目录,因此陈旧的同级检出目录仍可编辑。加载该 skill 会为整个会话解除门禁,却不会验证工作流是否确实得到遵循——门禁只能证明指令已被阅读,不能证明已被执行。满足状态按会话隔离,因此拥有独立会话的 subagent 必须自行加载该 skill。 + +## Testing + +单元测试套件基于真实 Git 元数据 fixture(测试前置数据),使用 mock 适配器驱动真实 agent loop(智能体循环):覆盖一个 staging worktree、嵌套其中的任务 worktree、普通克隆、位于 staging 命名分支上的其他仓库、HEAD 分离状态、绝对和相对 `gitdir:` 指针、指向同一仓库的符号链接路径、格式错误的指针以及不可读元数据,使两个源码文件都达到逐文件 100% 覆盖率。配套的 `invariant.ts` 会验证持久拒绝的结构,因为拒绝文本是该包唯一面向模型的输出,且只有其中列出路径、分支和 skill 时才具有可操作性。 + +真实组合冒烟测试通过 Loader 与 headless 应用启动 `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml`,并对组装后的运行断言三项事实:工具结果是错误、文本与拒绝理由逐字一致、目标文件仍保留原始字节。这证明系统在分发前强制执行规则,而不是事后给出建议。 + +一个 ACP(Agent Client Protocol)快照场景(`source-guard-staging-deny`)最初负责组装后的 transcript(文本记录),通过新的 `Scenario.prepareCwd` 钩子在 harness 生成的 cwd 中植入 staging worktree——Git 永远不会跟踪名为 `.git` 的条目,且 `.gitignore` 会排除所有 `worktrees/` 目录,因此 fixture 提交两个 `HEAD` 的内容,由钩子组装真实布局。编写它立刻证明了投入的价值:它暴露了上述两个路径缺陷(transcript 显示每当 guard 悄然不作判断时 `fs-policy` 都会率先响应),随后又发现了自身首版 fixture 的问题——被忽略的 `worktrees/` 路径因未跟踪文件而在本地通过。该场景后来被移除,组装运行证据合并进 Loader 组合冒烟测试;它引入的 `prepareCwd` 钩子仍留在快照 harness 中,服务于仓库形态的 fixture。 + +## Related + +- [个人 staging 维护 skill 的 Agent Note](../process/2026-07-23-personal-staging-maintenance-skills.md):本门禁负责执行该工作流的一条规则。对方 Agent Note 负责这些 skill 的内容与发现机制;本文只负责强制执行点,对工作流本身不具有定义权。 +- [拦截 seam Agent Note](2026-06-30-interception-seams.md):本门禁拒绝时使用的 `tools/pre-execute` `allow`/`deny`/`ask` 词汇。 +- [repeat-tool-guard Agent Note](../../archived/feature/2026-07-08-repeat-tool-guard.md):同类 guard;本文刻意不采用其建议性形态。 diff --git a/apps/cli/README.md b/apps/cli/README.md index 93c36d18ab..5241a29b4c 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -2,18 +2,24 @@ English | [中文](README.zh.md) -The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI. +The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, `dsh meta` boots that same TUI over this harness checkout, `dsh migrate` and `dsh upgrade` boot a fresh guided TUI session whose first turn invokes a bundled skill, `dsh list-sessions` lists the sessions running right now, and `dsh web` serves the browser UI. -Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. +Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`), whose `meta` subcommand is the same TUI over this checkout, whose `migrate`/`upgrade` subcommands are option-less guided-session entries, whose `list-sessions` subcommand (alias `ps`) lists live sessions, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `migrate`, `upgrade`, `list-sessions`, `web` — rejects a leaked `--config`/`-p`/`--resume` rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. The TUI surface: - boots the shipped default config (`examples/tui-agent/cordis.yml`), or the tree named by `--config ` (the demo/test escape for booting an alternate example tree), through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); -- resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized `dsh --resume `; runtimes without process replacement keep the displayed command fallback. The flag provides the id on the boot context under `RESUME_SESSION_ID_KEY` (no environment variable), which the shipped config reads through `!!js`, and a missing or unreadable id fails loud instead of creating a fresh session; -- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd; +- resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume invocation; runtimes without process replacement leave the session running and say so. This CLI owns session identity and the exit line rather than the config: it mints or selects the `main` session id and provides it, plus the exact command that reproduces this invocation, on the boot context ([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) and `TUI_GOODBYE_MESSAGE_KEY`). No `cordis.yml` key can drop resume, and a missing or unreadable id fails loud instead of creating a fresh session; +- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh meta` is the sole exception, below); - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. +`dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after both `.env` layers are loaded, so environment precedence is unchanged while the session cwd, the `./.sessions` persistence root, and the HMR watch root all move together. It accepts only `--resume `; `--config` (which would boot a foreign tree) and `-p` (which is not interactive) fail loud. Because meta sessions live under the checkout, `--resume` here sees only other meta sessions, and both the in-place handoff and the printed exit line reproduce the mode as `dsh meta --resume `, so a copied command resumes the right session from any directory. + +`dsh migrate` and `dsh upgrade` are guided fresh-session entries over the default TUI surface: each mints a fresh session in the invoking directory and seeds its first turn with a bundled skill (`dsh-migrate` for migrating from another coding agent — opencode, pi, Claude Code, Codex; `dsh-upgrade` for upgrading this checkout), exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection. + +`dsh list-sessions` lists the sessions running right now: session id, pid, uptime, workspace, and title, newest first. It is read-only and boots no agent tree — it mounts the [session registry](../../packages/session-registry/session-registry/README.md) alone, so listing is fast and cannot start model work as a side effect. Every surface publishes its sessions into that registry through [`dsh-session-registry-live`](../../packages/session-registry/session-registry-live/README.md), and records whose process is gone are pruned on read, so a crashed session disappears without cleanup. `--json` emits the same records as a machine-readable array; an empty listing prints one line and exits 0. There is no workspace filter: the listing is always every live session, whatever directory it runs in. Only top-level surfaces appear — subagents share or spawn other processes and are deliberately invisible. + The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 85f4624a59..69324735cb 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -2,18 +2,24 @@ [English](README.md) | 中文 -`dsh` 命令行入口遵循 `apps/` 组装层:`apps/*` 是位于 `packages/*` 库之上的产品组装。直接运行 `dsh` 会启动交互式 TUI 编码 agent(智能体),`dsh -p "task"` 运行一个无头轮次,`dsh web` 则提供浏览器 UI。 +`dsh` 命令行入口遵循 `apps/` 组装层:`apps/*` 是位于 `packages/*` 库之上的产品组装。直接运行 `dsh` 会启动交互式 TUI 编码 agent(智能体),`dsh -p "task"` 运行一个无头轮次,`dsh meta` 以本 harness checkout 为 workspace 启动同一个 TUI,`dsh migrate` 和 `dsh upgrade` 启动一个全新的引导式 TUI 会话并在首轮调用内置 skill,`dsh list-sessions` 列出此刻正在运行的会话,`dsh web` 则提供浏览器 UI。 -Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 +Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`migrate`/`upgrade` 子命令是无选项的引导会话入口,`list-sessions` 子命令(别名 `ps`)列出存活会话,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`migrate`、`upgrade`、`list-sessions`、`web`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 TUI 界面: - 启动已交付的默认配置(`examples/tui-agent/cordis.yml`),或由 `--config ` 指定的树(演示/测试用于启动其他示例树的逃生口),并通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 完成启动; -- 使用 `dsh --resume ` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的 `dsh --resume ` 替换进程;不支持进程替换的运行时保留屏幕上显示的命令回退。该标志通过 `RESUME_SESSION_ID_KEY` 在启动上下文中提供 id(不使用环境变量),已交付的配置通过 `!!js` 读取它;缺失或无法读取的 id 会明确报错,而不会创建新会话; -- 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析; +- 使用 `dsh --resume ` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的恢复调用替换进程;不支持进程替换的运行时会让会话继续运行并给出提示。会话身份与退出行由本 CLI 拥有,而非由配置指定:它创建或选定 `main` 会话 id,并把该 id 以及可复现本次调用的确切命令一起提供到启动上下文([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) 与 `TUI_GOODBYE_MESSAGE_KEY`)。任何 `cordis.yml` 键都无法移除恢复能力;缺失或无法读取的 id 会明确报错,而不会创建新会话; +- 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文); - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 +`dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在两层 `.env` 都加载之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd、`./.sessions` 持久化根目录与 HMR 监视根目录会一并移动。它只接受 `--resume `;`--config`(会启动其他配置树)和 `-p`(非交互)都会明确报错。由于 meta 会话位于该 checkout 之下,此处的 `--resume` 只能看到其他 meta 会话;原地移交与打印的退出行都会以 `dsh meta --resume ` 复现该 mode,因此复制的命令在任何目录下都能恢复到正确的会话。 + +`dsh migrate` 与 `dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:各自在调用目录中创建一个全新会话,并以一个内置 skill 播种其首轮(`dsh-migrate` 用于从其他编码 agent 迁移——opencode、pi、Claude Code、Codex;`dsh-upgrade` 用于升级本 checkout),效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。 + +`dsh list-sessions` 列出此刻正在运行的会话:会话 id、pid、运行时长、工作区和标题,最新的在前。它是只读的,不启动任何 agent 树——它只挂载[会话注册表](../../packages/session-registry/session-registry/README.md),因此列表既快,也不会作为副作用启动模型工作。每个界面都通过 [`dsh-session-registry-live`](../../packages/session-registry/session-registry-live/README.md) 把自己的会话发布到该注册表,进程已不存在的记录会在读取时被剪除,因此崩溃的会话无需清理便会消失。`--json` 以机器可读的数组形式输出同样的记录;空列表打印一行并以 0 退出。没有工作区过滤:列表始终是全部存活会话,无论它们运行在哪个目录下。只有顶层界面会出现——subagent 共用别的进程,或 spawn 出别的进程,因此被刻意排除在列表之外。 + Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 0914aaf5aa..783fff6487 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -70,6 +70,9 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", + "@deepseek-ai/dsh-session-registry": "workspace:^", + "@deepseek-ai/dsh-session-registry-file": "workspace:^", + "@deepseek-ai/dsh-session-registry-live": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 668d8f7f02..02bf596403 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -18,7 +18,7 @@ import Loader from '@cordisjs/plugin-loader' import Include, { type PatchOptions } from '@cordisjs/plugin-include' import yaml from 'js-yaml' import { assertEntriesLoaded, installFailLoud, loadEnv } from '@deepseek-ai/dsh-app-boot' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { resolveDshHome, resolveSessionsRoot } from '@deepseek-ai/dsh-paths' // Empty type import carries the httpServer Context merge for the port read below. import type {} from '@deepseek-ai/dsh-host-webserver' @@ -178,11 +178,11 @@ export class AppCLIEntry { overrides.set(entryId, bag) } - // Source 0: computed engineering defaults. The session store defaults to - // a global dir under the Harness home ($DSH_HOME, else ~/.dsh) so history - // is shared across every cwd, not a project-local ./.sessions. The profile + // Source 0: computed engineering defaults. The session store is the one + // shared root every dsh surface resolves, so history follows the user across + // working directories instead of splitting per project. The profile // (Source 1) overwrites this same field via last-write-wins in put(). - put('session-persistence-jsonl', 'root', join(resolveDshHome(), 'sessions')) + put('session-persistence-jsonl', 'root', resolveSessionsRoot()) // Source 1: profile json (missing file = empty; unmapped key = loud). for (const [key, value] of Object.entries(this.readProfile())) { @@ -209,6 +209,7 @@ export class AppCLIEntry { // user config. Workspace knowledge stays here. put('webserver', 'distIndex', this.resolveDistIndex()) + this.patches = [...overrides.entries()].map(([id, bag]) => { const yml = rows.get(id) if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`) diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index b929dc73f2..d143430ed4 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -2,9 +2,9 @@ * Commander adapter for the `dsh` command-line entry: the one place argv is * parsed and routed to a mode. `bin.ts` switches on the returned discriminant * and dynamic-imports that mode's module. One program: the default (no - * subcommand) is the TUI/headless surface with option-only flags; `web` is a - * real subcommand. Commander owns `--help`/`--version` and parse errors — it - * prints and exits at the point of failure (a domain failure routes through + * subcommand) is the TUI/headless surface with option-only flags; `meta` and + * `web` are real subcommands. Commander owns `--help`/`--version` and parse + * errors — it prints and exits at the point of failure (a domain failure routes through * `command.error`), so this returns only a resolved mode. * @module @deepseek-ai/dsh/args */ @@ -24,6 +24,39 @@ interface HeadlessInvocation { prompt: string } +/** + * Interactive TUI over this harness checkout: `dsh meta`. Identical to + * {@link TuiInvocation} except the workspace is the launcher's own source tree + * rather than the invoking directory. No `--config`: booting a foreign tree + * against the harness workspace is the `--config` case, not this one. + */ +interface MetaInvocation { + mode: 'meta' + resume?: string +} + +/** + * Guided fresh-session entries: `dsh migrate` seeds the first turn with the + * `dsh-migrate` skill, `dsh upgrade` with `dsh-upgrade`. Each always mints a + * fresh session in the invoking directory and takes no options — `--resume`, + * `--config`, and `-p` are rejected as mistyped, so there is nothing to carry. + */ +interface SkillSessionInvocation { + mode: 'migrate' | 'upgrade' +} + +/** + * List live sessions: `dsh list-sessions` (alias `dsh ps`). A read-only surface + * that boots no agent tree — it reads the cross-process session registry and + * exits. `json` selects the machine-readable form over the human table. There + * is no workspace filter: the listing is always every live session, whatever + * directory it runs in. + */ +interface ListSessionsInvocation { + mode: 'list-sessions' + json: boolean +} + /** * Browser UI: `dsh web`. `host`/`port` are present only when the flag was * passed — pass-through overrides with no CLI default and no CLI validation: @@ -45,7 +78,13 @@ interface WebInvocation { } /** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */ -export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation +export type DshInvocation = + | TuiInvocation + | HeadlessInvocation + | MetaInvocation + | SkillSessionInvocation + | ListSessionsInvocation + | WebInvocation /** Raw web-subcommand options straight from Commander. */ interface WebOptions { @@ -86,13 +125,22 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc const program = new Command() .name('dsh') .version(version, '-V, --version', 'output the version number') - .description('dsh: interactive TUI (default), headless task, and browser UI') + .description('dsh: DeepSeek Harness — an interactive coding agent for your terminal.\nRun `dsh` with no arguments to start a session in the current directory.') + // The default surface takes no positional task, so `dsh "task"` fails + // commander's arity check with no hint; these examples are where a first + // reader learns the entry points and that a one-shot task rides `-p`. + .addHelpText('after', ` +Examples: + dsh start an interactive session in this directory + dsh -p "run the tests" answer one task, print the result, and exit + dsh --resume continue a past session (list ids with \`dsh ps\`) +`) .exitOverride() // Default surface: option-only (no positional), so `web` can be a real // subcommand without a positional collision. - .option('--config ', 'boot an alternate cordis.yml instead of the shipped tree (TUI mode)') - .option('-p, --prompt ', 'run one headless turn for this task, print the result, and exit') - .option('--resume ', 'resume the persisted session with this id (TUI mode)') + .option('-p, --prompt ', 'answer this task without the interactive UI, then exit') + .option('--resume ', 'continue a past session by id (list ids with `dsh ps`)') + .option('--config ', 'start with an alternate plugin configuration file') .action((options: { config?: string; prompt?: string; resume?: string }) => { if (options.prompt !== undefined) { // A headless prompt owns the invocation; an empty task has nothing to @@ -115,25 +163,84 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc } }) - const web = program.command('web').description('serve the browser UI (host/port default to the shipped config)') + // Commander parses the parent (default-surface) options on either side of a + // subcommand into `program.opts()`. For a subcommand that shares none of them, + // a leaked `--config`/`-p`/`--resume` is a mistyped invocation that must fail + // loud rather than silently run and drop the input. + const rejectParentOptions = (command: string): void => { + const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>() + if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) { + program.error(`error: ${command} takes none of --config, -p/--prompt, or --resume`) + } + } + + // Registration order is the rendered help order, so daily use comes first + // and the harness-development surfaces (`web --dev`, `meta`) come last. + // `migrate` and `upgrade` are guided fresh-session entries: they take no + // options and always mint a fresh session, so nothing is left to carry. Each + // description names the outcome, not the skill the first turn invokes. + const guided = { + migrate: 'import settings from another coding agent (Claude Code, Codex, opencode)', + upgrade: 'update this dsh installation to the latest version', + } as const + for (const mode of ['migrate', 'upgrade'] as const) { + program + .command(mode) + .description(guided[mode]) + .action(() => { + rejectParentOptions(mode) + resolved = { mode } + }) + } + + program + .command('list-sessions') + .alias('ps') + .description('list sessions running right now') + .option('--json', 'print the records as a JSON array instead of a table') + .action((options: { json?: boolean }) => { + rejectParentOptions('list-sessions') + resolved = { mode: 'list-sessions', json: options.json === true } + }) + + // Host and port name no default: the CLI passes neither through when the flag + // is absent, so the shipped `cordis.yml` value stands and restating it here + // would duplicate a fact this file does not own. + const web = program.command('web').description('serve the browser UI on the configured host and port') web - .option('--host ', 'override the config bind host (127.0.0.1 or 0.0.0.0)') - .option('--port ', 'override the config listen port (0 requests an OS-assigned port)') - .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds') - .option('--workspace-root ', 'parent directory for name-created workspaces') + .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') + .option('--port ', 'listen port; pass 0 to let the OS pick a free one') + .option('--dev', 'developer mode: hot-reload the browser client') + .option('--workspace-root ', 'parent directory for workspaces created from the browser UI') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .action((options: WebOptions) => { - // Commander parses the parent (default-surface) options on either side of - // the subcommand into `program.opts()`. `web` shares none of them, so a - // leaked `--config`/`-p`/`--resume` is a mistyped invocation that must - // fail loud rather than silently start the web server and drop it. - const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>() - if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) { - program.error('error: web takes none of --config, -p/--prompt, or --resume') - } + rejectParentOptions('web') resolved = resolveWeb(options) }) + // `--resume` is NOT redeclared here: an option a subcommand shares with its + // parent parses into `program.opts()` and leaves the subcommand's own options + // empty, so redeclaring it would silently drop the id. Commander therefore + // omits it from this subcommand's option list, hence the trailing help text. + program + .command('meta') + .description('work on the dsh source that runs this command, from any directory') + .addHelpText('after', '\nAccepts --resume to resume a persisted session from this checkout.\n') + .action(() => { + // Commander parses the parent (default-surface) options on either side of + // the subcommand into `program.opts()`. `meta` accepts only `--resume`, so + // a leaked `--config`/`-p` is a mistyped invocation that must fail loud + // rather than silently be dropped. + const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>() + if (parent.config !== undefined || parent.prompt !== undefined) { + program.error('error: meta takes neither --config nor -p/--prompt') + } + // Same reason as the default surface: an empty id would start a fresh + // session downstream instead of failing the mistyped resume. + if (parent.resume === '') program.error('error: --resume needs a session id') + resolved = { mode: 'meta', ...parent.resume !== undefined && { resume: parent.resume } } + }) + try { program.parse(argv, { from: 'user' }) } catch (error) { diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 88dbece55a..8079db1cd4 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -43,6 +43,22 @@ switch (invocation.mode) { await runTui(invocation.config, invocation.resume) break } + case 'meta': { + const { runMeta } = await import('./tui.ts') + await runMeta(invocation.resume) + break + } + case 'list-sessions': { + const { runListSessions } = await import('./list-sessions.ts') + await runListSessions(invocation.json) + break + } + case 'migrate': + case 'upgrade': { + const { runSkillSession } = await import('./tui.ts') + await runSkillSession(`dsh-${invocation.mode}`) + break + } default: invocation satisfies never throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`) diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index d7588ccae4..7ee18c1ea1 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -14,6 +14,7 @@ import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api' import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import type { SessionId } from '@deepseek-ai/dsh-session' import { AppCLIEntry } from './app-cli-entry.ts' +import { registerLiveSessions } from './register-session.ts' /** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */ interface TurnOutcome { @@ -80,6 +81,7 @@ export async function runHeadless(task: string): Promise { port: 0, }) const { ctx, port } = await entry.run() + await registerLiveSessions(ctx) const dispose = async (): Promise => { await ctx.fiber.dispose() } // The headless session is web-observable while it runs (same composition). process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`) diff --git a/apps/cli/src/list-sessions.ts b/apps/cli/src/list-sessions.ts new file mode 100644 index 0000000000..08a972f402 --- /dev/null +++ b/apps/cli/src/list-sessions.ts @@ -0,0 +1,101 @@ +/** + * `dsh list-sessions` (alias `dsh ps`) — list the sessions running right now. + * + * A read-only surface: it mounts the session registry alone and never boots an + * agent tree, so listing stays fast and cannot start model work as a side + * effect. Liveness comes from the registry, which prunes records whose process + * is gone, and every displayed field including the title comes from the record, + * so no session log is opened and no backend format is assumed. + * @module @deepseek-ai/dsh/list-sessions + */ + +import { Context } from 'cordis' +import { type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry' +import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file' +import { registryRoot } from './register-session.ts' + +/** Column header text, also the minimum width of each column. */ +const HEADERS = ['SESSION', 'PID', 'UPTIME', 'WORKSPACE', 'TITLE'] as const + +/** Shown when a session has no title yet. */ +const NO_TITLE = '—' + +/** + * Render milliseconds of uptime as a compact human duration. + * @param ms - elapsed milliseconds since the session registered. + * @returns a short duration such as `12s`, `4m`, or `2h14m`. + */ +export function formatUptime(ms: number): string { + const seconds = Math.max(0, Math.floor(ms / 1000)) + if (seconds < 60) return `${String(seconds)}s` + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${String(minutes)}m` + const hours = Math.floor(minutes / 60) + const remainder = minutes % 60 + if (hours < 24) return remainder === 0 ? `${String(hours)}h` : `${String(hours)}h${String(remainder)}m` + const days = Math.floor(hours / 24) + const leftoverHours = hours % 24 + return leftoverHours === 0 ? `${String(days)}d` : `${String(days)}d${String(leftoverHours)}h` +} + +/** One fully-resolved listing row, in column order. */ +type Row = readonly [string, string, string, string, string] + +/** + * Build the display rows for a listing, newest session first. + * @param records - the live records to render. + * @param now - the current epoch milliseconds uptime is measured against. + * @returns one row per record, each already stringified per column. + */ +export function buildRows(records: readonly SessionRegistryRecord[], now: number): Row[] { + return [...records] + .sort((left, right) => right.startedAt - left.startedAt) + .map(record => [ + record.sessionId, + String(record.pid), + formatUptime(now - record.startedAt), + record.cwd, + record.title ?? NO_TITLE, + ] as const) +} + +/** + * Render rows as a left-aligned table with a header line. + * + * The last column is never padded, so a long title cannot add trailing + * whitespace to every line. + * @param rows - the rows to render, already stringified. + * @returns the complete table text, newline-terminated. + */ +export function renderTable(rows: readonly Row[]): string { + const widths = HEADERS.map((header, column) => + Math.max(header.length, ...rows.map(row => row[column]?.length ?? 0))) + const line = (cells: readonly string[]): string => + cells.map((cell, column) => column === cells.length - 1 ? cell : cell.padEnd(widths[column] ?? 0)).join(' ').trimEnd() + return [line(HEADERS), ...rows.map(row => line(row))].join('\n') + '\n' +} + +/** + * List live sessions and exit. Prints a table by default, or a JSON array with + * `--json`; an empty listing is a success, not an error. + * @param json - emit the machine-readable JSON array instead of the table. + */ +export async function runListSessions(json: boolean): Promise { + const ctx = new Context() + await ctx.plugin(SessionRegistryFile, { root: registryRoot() }) + const records = await ctx.sessionRegistry.list() + await ctx.fiber.dispose() + + if (json) { + const rows = [...records] + .sort((left, right) => right.startedAt - left.startedAt) + .map(record => ({ ...record, uptimeMs: Date.now() - record.startedAt, title: record.title ?? null })) + process.stdout.write(`${JSON.stringify(rows, undefined, 2)}\n`) + return + } + if (records.length === 0) { + process.stdout.write('no dsh sessions running\n') + return + } + process.stdout.write(renderTable(buildRows(records, Date.now()))) +} diff --git a/apps/cli/src/register-session.ts b/apps/cli/src/register-session.ts new file mode 100644 index 0000000000..ff78456e8a --- /dev/null +++ b/apps/cli/src/register-session.ts @@ -0,0 +1,44 @@ +/** + * Mounts the cross-process live-session registry that `dsh list-sessions` reads, plus the + * publisher that keeps it in step with this process's sessions. + * + * Both plugins mount on the booted app's own context, so records share that + * fiber's lifetime: an ordinary exit disposes the fiber and deregisters, while a + * killed process leaves records the next reader prunes by pid. Only top-level + * surfaces a user launches mount this — in-process subagents have no process of + * their own, and out-of-process subagent backends spawn `dsh-jsonrpc-agent` + * rather than this CLI, so neither reaches this path. + * @module @deepseek-ai/dsh/register-session + */ + +import { join } from 'node:path' +import type { Context } from 'cordis' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file' +import * as sessionRegistryLive from '@deepseek-ai/dsh-session-registry-live' + +/** Registry root under the Harness home, shared by every surface and by `dsh list-sessions`. */ +export const registryRoot = (): string => join(resolveDshHome(), 'run') + +/** + * Publish this process's sessions for the lifetime of `ctx`. + * + * Publication follows session lifecycle rather than a launcher-known id, so one + * path serves every surface identically — the TUI's single session and a + * server's on-demand ones alike — and titles reach the listing as they are + * logged. + * + * Mounting is best-effort: a registry failure must not take down a working agent + * session, because the registry is an observability aid rather than part of the + * agent's contract. Failures warn through the context logger. + * @param ctx - the booted app context whose lifetime the records share. + */ +export async function registerLiveSessions(ctx: Context): Promise { + try { + const scope = ctx.isolate('sessionRegistry') + await scope.plugin(SessionRegistryFile, { root: registryRoot() }) + await scope.plugin(sessionRegistryLive) + } catch (error) { + ctx.logger('dsh').warn('session registry unavailable; `dsh list-sessions` will not list these sessions: %s', String(error)) + } +} diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 0d402c1c51..fb1c354d46 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -4,13 +4,20 @@ * from the Harness home (`~/.dsh`): its `.env` fills environment gaps (precedence: * ambient environment, then the invoking directory's `.env`, then the personal one) * and its `config.yaml` patches the booted tree. The workspace is the invoking - * directory: sessions, relative paths, and workspace instructions resolve from - * the cwd, so `dsh` acts on whatever project it is launched in. After boot, the - * agent's system prompt is told the path to this harness checkout so it can find - * its own source. + * directory: the session cwd, relative paths, and workspace instructions resolve + * from it, so `dsh` acts on whatever project it is launched in. Session storage + * is the exception — it lives under the Harness home so `/resume` reaches every + * workspace, and an in-place resume enters the selected session's own directory. + * `dsh meta` + * ({@link runMeta}) is the one exception — it makes this harness checkout the + * workspace. `dsh migrate`/`dsh upgrade` ({@link runSkillSession}) are fresh + * sessions whose first turn auto-invokes a bundled skill. After boot, the + * agent's system prompt is told the path to this harness checkout so it can + * find its own source. * @module @deepseek-ai/dsh/tui */ +import { randomUUID } from 'node:crypto' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { @@ -19,13 +26,18 @@ import { installFailLoud, loadEnv, loadPersonalPatches, - RESUME_SESSION_ID_KEY, resolveConfigPath, } from '@deepseek-ai/dsh-app-boot' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { resolveDshHome, resolveSessionsRoot } from '@deepseek-ai/dsh-paths' +import { SessionId } from '@deepseek-ai/dsh-session' import type { Context } from 'cordis' +import { registerLiveSessions } from './register-session.ts' import { + INITIAL_SKILL_KEY, + MAIN_SESSION_ID_KEY, + SESSIONS_ROOT_KEY, TUI_GOODBYE_MESSAGE_KEY, + type MainSessionIdentity, type TuiResumeHost, } from '@deepseek-ai/dsh-tui' @@ -41,18 +53,65 @@ const DEFAULT_CONFIG = fileURLToPath(new URL('../../../examples/tui-agent/cordis // symlink, an arbitrary cwd). The agent is told where its own source lives. const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) +/** + * The value `dsh` provides on the {@link SESSIONS_ROOT_KEY} boot slot: its + * shared session-store root, `sessions` under the Harness home. Shared-store + * policy is the launcher's alone — the app bundle treats the slot as opaque and + * keeps a project-local fallback, so only `dsh` decides that sessions are + * shared across working directories (making `/resume` and `list-sessions` span + * every workspace). + * @returns the absolute session-store root this launcher shares. + */ +export function launcherSessionsRoot(): string { + return resolveSessionsRoot() +} + /* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers; the tui-agent PTY smoke drives this path end to end, personal overlay included */ +/** + * Run the interactive TUI with this harness checkout as the workspace + * (`dsh meta`), whatever directory it was launched from. + * @param resumeSessionId - a persisted session id to resume, or `undefined`; + * see {@link runTui}. Meta-mode sessions live under the checkout, so an id from + * an ordinary `dsh` run in another directory is not found here. + */ +export async function runMeta(resumeSessionId: string | undefined): Promise { + return runTui(undefined, resumeSessionId, SOURCE_ROOT) +} + +/** + * Run the interactive TUI as a guided fresh session whose first turn invokes a + * bundled skill (`dsh migrate` → `dsh-migrate`, `dsh upgrade` → `dsh-upgrade`). + * Always mints a fresh session in the invoking directory; the skill is seeded + * only on this first launch, so a later `--resume` of the session is an ordinary + * TUI session with no re-injection. + * @param skill - the bundled skill name to auto-invoke as the first turn. + */ +export async function runSkillSession(skill: string): Promise { + return runTui(undefined, undefined, undefined, skill) +} + /** * Run the interactive TUI from the invoking directory. * @param config - a config path to boot instead of the shipped default, or * `undefined` for the default; already parsed from `--config`. - * @param resumeSessionId - a persisted session id to resume, or `undefined`; - * already parsed and non-empty-validated from `--resume`. It is provided on the - * boot context under {@link RESUME_SESSION_ID_KEY}, which the shipped config - * reads through `!!js` to rehydrate that session. + * @param resumeSessionId - a persisted session id to resume, or `undefined` to + * mint a fresh one; already parsed and non-empty-validated from `--resume`. + * Either way the resulting identity reaches the booted app through + * {@link MAIN_SESSION_ID_KEY}, so no config key selects the session. + * @param workspace - a directory to make the workspace instead of the invoking + * one, or `undefined` to keep the cwd. Only `dsh meta` passes it. + * @param initialSkill - a bundled skill to auto-invoke as a fresh session's + * first turn, or `undefined`. Set only by {@link runSkillSession} and ignored + * on a resume, so it never re-fires; reaches the app through + * {@link INITIAL_SKILL_KEY}. */ -export async function runTui(config: string | undefined, resumeSessionId: string | undefined): Promise { +export async function runTui( + config: string | undefined, + resumeSessionId: string | undefined, + workspace?: string, + initialSkill?: string, +): Promise { // Refuse pipes BEFORE booting: a compose-time throw inside the Loader tree // is logged per-entry rather than rethrown, so a piped launch would // otherwise settle into an idle UI-less process instead of exiting nonzero. @@ -66,28 +125,52 @@ export async function runTui(config: string | undefined, resumeSessionId: string // The bin already loaded the invoking directory's .env; the personal .env // only fills what is still unset (process.loadEnvFile never overrides). loadEnv(NAME, resolveDshHome()) + // Both .env layers are loaded, so switching the workspace here cannot alter + // environment precedence. The cwd IS the workspace seam: the shipped config + // resolves the session cwd and the HMR watch root from it, so one chdir moves + // both together. Sessions themselves live under the Harness home so `/resume` + // spans every workspace, and are unaffected by this chdir. + if (workspace !== undefined) process.chdir(workspace) process.env.DSH_BUNDLED_SKILL_DIR = join(SOURCE_ROOT, 'skills') // The in-place `/resume` handoff re-execs `dsh` with a normalized `--resume` - // flag, so the resumed process rehydrates through this same intake. The host - // is offered only when Node exposes `process.execve` and knows its own entry. + // flag, so the resumed process rehydrates through this same intake. The + // selected session may belong to another workspace, so the handoff also enters + // that directory. The host is offered only when Node exposes `process.execve` + // and knows its own entry. const entry = process.argv[1] const execve = process.execve?.bind(process) const app: { current?: Context } = {} - const resumeCommand = (sessionId: string): string => - `${NAME} --resume=${sessionId}${config === undefined ? '' : ` --config ${config}`}` + // Resuming reproduces THIS invocation with a different id. Meta mode is a + // subcommand that rejects `--config`, while the default surface carries it, so + // both the in-place handoff and the printed command derive from one shape. + // `meta` is only reproducible for a target inside this checkout: it chdirs to + // SOURCE_ROOT itself, which would override any other workspace, so a + // cross-workspace resume takes the default surface and the caller supplies the + // directory instead. + const resumeArgs = (sessionId: string, targetCwd?: string): string[] => + workspace !== undefined && (targetCwd === undefined || targetCwd === workspace) + ? ['meta', `--resume=${sessionId}`] + : [`--resume=${sessionId}`, ...config !== undefined ? ['--config', config] : []] + // Mint the fresh id here rather than in the app bundle: the exit line names + // the session to resume, so the launcher must know it before the tree boots. + const identity: MainSessionIdentity = resumeSessionId === undefined + ? { id: SessionId(`main-session-${randomUUID()}`), resume: false } + : { id: SessionId(resumeSessionId), resume: true } + const goodbye = `To resume this session: ${NAME} ${resumeArgs(identity.id).join(' ')}` const resumeHost: TuiResumeHost | undefined = entry === undefined || execve === undefined ? undefined : { async handoff(sessionId, cwd): Promise { const current = app.current if (current === undefined) throw new Error(`${NAME}: app boot has not completed`) - // Rebuild argv from the parsed config plus the selected id: TUI mode's - // only arguments are `--config ` and `--resume `. const nextArgv = [ process.execPath, ...process.execArgv, entry, - `--resume=${sessionId}`, - ...config !== undefined ? ['--config', config] : [], + ...resumeArgs(sessionId, cwd), ] + // `execve` inherits the cwd, and the target session may belong to another + // workspace. Enter it BEFORE teardown commits: an unreachable directory + // (deleted, unreadable) must reject while the caller can still restore the + // terminal, and a chdir after disposal would have no owner to report to. try { process.chdir(cwd) } catch (error) { @@ -108,16 +191,27 @@ export async function runTui(config: string | undefined, resumeSessionId: string resolveConfigPath(config ?? DEFAULT_CONFIG, undefined), loadPersonalPatches(NAME), (hostCtx) => { - // Inject the resume id (or undefined) so the shipped config's `!!js` - // reads it as a bare identifier; then offer the in-place handoff host. - hostCtx.provide(RESUME_SESSION_ID_KEY, resumeSessionId) - if (resumeSessionId !== undefined) { - hostCtx.provide(TUI_GOODBYE_MESSAGE_KEY, `To resume this session: ${resumeCommand(resumeSessionId)}`) - } + // The launcher owns session identity and the exit line: a config-mounted + // app bundle reads both from these slots, so no cordis.yml key can drop + // resume. + hostCtx.provide(MAIN_SESSION_ID_KEY, identity) + hostCtx.provide(TUI_GOODBYE_MESSAGE_KEY, goodbye) + // Shared-store policy is the launcher's: sessions live in one root under + // the Harness home across every cwd, so /resume and list-sessions see + // every workspace. The bundle treats the slot as opaque. + hostCtx.provide(SESSIONS_ROOT_KEY, launcherSessionsRoot()) if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost) + // Seed the first turn only for a fresh session, so resuming never + // re-invokes the skill. + if (initialSkill !== undefined && resumeSessionId === undefined) { + hostCtx.provide(INITIAL_SKILL_KEY, initialSkill) + } }, ) app.current = ctx addHarnessSourceSection(ctx, SOURCE_ROOT) + // Publication follows the store; meta mode already chdir'd, so each session + // reports its own cwd. + await registerLiveSessions(ctx) } /* v8 ignore stop */ diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 69e79ab5d9..f4ad016005 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url' import { AppCLIEntry } from './app-cli-entry.ts' +import { registerLiveSessions } from './register-session.ts' const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) @@ -40,6 +41,7 @@ export async function runWeb( ...trustedHosts !== undefined && { trustedHosts }, }) const { ctx, port: boundPort } = await entry.run() + await registerLiveSessions(ctx) let exiting = false const shutdown = (code: number): void => { diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 45830eee30..99569c7cf0 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -24,17 +24,33 @@ function exitCode(argv: string[]): number { afterEach(() => { vi.restoreAllMocks() }) describe('parseDshArgs', () => { - it('routes each mode by its shape: default TUI, -p headless, web subcommand', () => { + it('routes each mode by its shape: default TUI, -p headless, meta and web subcommands', () => { expect(parse([])).toEqual({ mode: 'tui' }) expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' }) expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) + // `meta` accepts `--resume` but does not redeclare it: a shared option parses + // into program.opts() on either side of the subcommand, and redeclaring it + // would leave the subcommand's own options empty and drop the id. + expect(parse(['meta'])).toEqual({ mode: 'meta' }) + expect(parse(['meta', '--resume', 'sess'])).toEqual({ mode: 'meta', resume: 'sess' }) + expect(parse(['--resume', 'sess', 'meta'])).toEqual({ mode: 'meta', resume: 'sess' }) + // Credential setup is option-free: it writes the Harness-home .env, so + // there is nothing for a flag to select. // Bare `web` carries no host/port: the shipped cordis.yml owns the default. expect(parse(['web'])).toEqual({ mode: 'web', dev: false }) // Host/port are unvalidated pass-throughs (the webserver schema gates them // at boot); the adapter only coerces the port string to a number. expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' }) + // Guided fresh-session entries carry nothing: bare mode discriminant only. + expect(parse(['migrate'])).toEqual({ mode: 'migrate' }) + expect(parse(['upgrade'])).toEqual({ mode: 'upgrade' }) + // `list-sessions` has one option and no workspace filter: the listing is always + // global. `ps` is its alias and resolves to the same mode. + expect(parse(['list-sessions'])).toEqual({ mode: 'list-sessions', json: false }) + expect(parse(['list-sessions', '--json'])).toEqual({ mode: 'list-sessions', json: true }) + expect(parse(['ps', '--json'])).toEqual({ mode: 'list-sessions', json: true }) // --trusted-host is variadic and repeatable; authorities pass through unvalidated. expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) .toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) @@ -55,6 +71,27 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '-p', 'task'])).toBe(1) expect(exitCode(['web', '--resume', 's'])).toBe(1) expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1) + // Same rule for credential setup: it shares no option with the default + // surface, so a leaked flag is a typo, not something to ignore. + // `meta` fixes its own config tree and is interactive, so --config/-p are + // rejected; an empty id is swallowed downstream exactly as above. + expect(exitCode(['meta', '--resume='])).toBe(1) + expect(exitCode(['meta', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['meta', '-p', 'task'])).toBe(1) + // `migrate`/`upgrade` take no options: any leaked default-surface flag is a + // mistyped invocation, not a silently-dropped input. + expect(exitCode(['migrate', '--resume', 's'])).toBe(1) + expect(exitCode(['migrate', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['migrate', '-p', 'task'])).toBe(1) + expect(exitCode(['upgrade', '--resume', 's'])).toBe(1) + expect(exitCode(['upgrade', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['-p', 'task', 'upgrade'])).toBe(1) + // `list-sessions`/`ps` is read-only and shares no default-surface option: a leaked flag is a + // mistyped invocation, not a listing with a silently dropped input. + expect(exitCode(['ps', '--resume', 's'])).toBe(1) + expect(exitCode(['list-sessions', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['list-sessions', '-p', 'task'])).toBe(1) + expect(exitCode(['--resume', 's', 'ps'])).toBe(1) }) it('exits 0 for --help (disclosing web) and --version', () => { diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 1ea7d9f0db..71c8257b08 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -1,8 +1,9 @@ -import { existsSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { execa } from 'execa' -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' /** * Published-entry smoke for the `dsh` bin: run the built `lib/bin.js` under @@ -16,19 +17,28 @@ import { describe, expect, it } from 'vitest' * node_modules, so no external consumer is assembled; missing-config fail-loud * and full-boot coverage for the shared dsh-app-boot glue live in cli-demo's * built-bin suite, and interactive TTY behavior is PTY-covered by - * examples/tui-agent. Skips before the bin is built. + * examples/tui-agent. `dsh list-sessions` is covered here too: it is the one surface that + * boots no agent tree, so the built bin is the whole product path. + * Skips before the bin is built. */ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') -/** Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output + exit code. */ -async function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> { - const result = await execa(process.execPath, [dshBin], { +/** + * Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output + * + exit code. `env` isolates the Harness home for surfaces that read it. + */ +async function runBuiltBin( + args: readonly string[] = [], + env: Record = {}, +): Promise<{ stdout: string; code: number; stderr: string }> { + const result = await execa(process.execPath, [dshBin, ...args], { input: '', timeout: 25_000, killSignal: 'SIGKILL', reject: false, + env, }) if (result.timedOut) { throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) @@ -45,4 +55,37 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', // The refusal happens before any plugin mounts: stdout stays silent. expect(stdout).toBe('') }, 30_000) + + describe('dsh list-sessions', () => { + let home: string + beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-ls-bin-')) }) + afterEach(() => { rmSync(home, { recursive: true, force: true }) }) + + it('reports an empty listing as success, not an error', async () => { + const { stdout, code, stderr } = await runBuiltBin(['list-sessions'], { DSH_HOME: home }) + expect(code).toBe(0) + expect(stdout.trim()).toBe('no dsh sessions running') + expect(stderr).toBe('') + }, 30_000) + + it('emits an empty JSON array for machines', async () => { + const { stdout, code } = await runBuiltBin(['ps', '--json'], { DSH_HOME: home }) + expect(code).toBe(0) + expect(JSON.parse(stdout)).toEqual([]) + }, 30_000) + + it('runs without a TTY, unlike the TUI surface', async () => { + // The listing is read-only and boots no agent tree, so piped stdio — the + // launch the TUI refuses — is a supported way to run it. + const { code, stderr } = await runBuiltBin(['ps'], { DSH_HOME: home }) + expect(code).toBe(0) + expect(stderr).not.toContain('interactive TTYs') + }, 30_000) + + it('rejects a leaked default-surface flag instead of listing', async () => { + const { code, stderr } = await runBuiltBin(['list-sessions', '--resume', 'sess'], { DSH_HOME: home }) + expect(code).not.toBe(0) + expect(stderr).toContain('list-sessions takes none of') + }, 30_000) + }) }) diff --git a/apps/cli/tests/list-sessions.spec.ts b/apps/cli/tests/list-sessions.spec.ts new file mode 100644 index 0000000000..a73faf1b20 --- /dev/null +++ b/apps/cli/tests/list-sessions.spec.ts @@ -0,0 +1,74 @@ +/** + * Tests for the `dsh list-sessions` presentation layer: uptime formatting, row building + * (newest first, absent-title placeholder) and table alignment without trailing + * padding. Every displayed field comes from the record, so there is no log + * reading to cover here. + */ + +import { describe, expect, it } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import { BootId, type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry' +import { buildRows, formatUptime, renderTable } from '../src/list-sessions.ts' + +function record(overrides: Partial = {}): SessionRegistryRecord { + return { + sessionId: SessionId('sess-1'), + pid: 4242, + cwd: '/work/project', + startedAt: 1_000, + bootId: BootId('boot-1'), + ...overrides, + } +} + +describe('formatUptime', () => { + it.each([ + [0, '0s'], + [999, '0s'], + [12_000, '12s'], + [59_999, '59s'], + [60_000, '1m'], + [3_540_000, '59m'], + [3_600_000, '1h'], + [8_040_000, '2h14m'], + [86_400_000, '1d'], + [90_000_000, '1d1h'], + ])('renders %ims as %s', (ms, expected) => { + expect(formatUptime(ms)).toBe(expected) + }) + + it('never renders a negative duration for a clock that moved backwards', () => { + expect(formatUptime(-5_000)).toBe('0s') + }) +}) + +describe('buildRows', () => { + it('orders newest first and marks a missing title', () => { + const rows = buildRows([ + record({ sessionId: SessionId('older'), startedAt: 1_000 }), + record({ sessionId: SessionId('newer'), startedAt: 5_000 }), + ], 65_000) + expect(rows.map(row => row[0])).toEqual(['newer', 'older']) + expect(rows[0]).toEqual(['newer', '4242', '1m', '/work/project', '—']) + }) +}) + +describe('renderTable', () => { + it('aligns columns and leaves no trailing whitespace', () => { + const table = renderTable(buildRows([ + record({ sessionId: SessionId('short'), startedAt: 0, title: 'a title' }), + record({ sessionId: SessionId('a-much-longer-session-id'), startedAt: 1, title: 'a title' }), + ], 1_000)) + const lines = table.split('\n') + expect(lines[0]).toMatch(/^SESSION {18}\s+PID/) + for (const line of lines) expect(line).toBe(line.trimEnd()) + // The header and every row align on the same column starts. + const pidColumn = (line: string): number => line.includes('4242') ? line.indexOf('4242') : line.indexOf('PID') + expect(pidColumn(lines[1] ?? '')).toBe(pidColumn(lines[0] ?? '')) + expect(pidColumn(lines[2] ?? '')).toBe(pidColumn(lines[0] ?? '')) + }) + + it('renders a header even with no rows, so the columns stay discoverable', () => { + expect(renderTable([])).toBe('SESSION PID UPTIME WORKSPACE TITLE\n') + }) +}) diff --git a/apps/cli/tests/sessions-root.spec.ts b/apps/cli/tests/sessions-root.spec.ts new file mode 100644 index 0000000000..8f12455f1b --- /dev/null +++ b/apps/cli/tests/sessions-root.spec.ts @@ -0,0 +1,20 @@ +/** + * Pins the launcher side of the shared-session-store contract: `dsh` defaults + * its opaque `SESSIONS_ROOT_KEY` boot-slot value to `DSH_HOME/sessions`. The + * plugin side — the slot treated as opaque, explicit config winning, and a + * project-local fallback with no globality assumption — is pinned by + * `packages/examples/tui-demo/tests/tui-agent.spec.ts`. + */ + +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { launcherSessionsRoot } from '../src/tui.ts' + +afterEach(() => vi.unstubAllEnvs()) + +describe('launcherSessionsRoot', () => { + it('defaults the boot slot to sessions under DSH_HOME', () => { + vi.stubEnv('DSH_HOME', '/tmp/dsh-slot-home') + expect(launcherSessionsRoot()).toBe(resolve(join('/tmp/dsh-slot-home', 'sessions'))) + }) +}) diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index ce43c0f31c..ef0a6705ad 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -29,6 +29,15 @@ { "path": "../../packages/util/paths" }, + { + "path": "../../packages/session-registry/session-registry" + }, + { + "path": "../../packages/session-registry/session-registry-file" + }, + { + "path": "../../packages/session-registry/session-registry-live" + }, { "path": "../../packages/client/connection" }, diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 43f5b20716..6de0f7ff6e 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -47,6 +47,10 @@ flowchart LR pkg_workspace["workspace"] svc_workspace["ctx.workspace
Workspace entity registry"] pkg_apiproxy["apiproxy"] + pkg_session_registry["session-registry"] + svc_sessionRegistry["ctx.sessionRegistry
Live-session registry"] + pkg_session_registry_file["session-registry-file"] + pkg_session_registry_live["session-registry-live"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] pkg_tool_session_query["tool-session-query"] @@ -197,6 +201,8 @@ flowchart LR pkg_session_query --> svc_sessionQuery pkg_session_query_sqlite --> svc_sessionQuery pkg_session_reference --> svc_sessionReferences + pkg_session_registry --> svc_sessionRegistry + pkg_session_registry_file --> svc_sessionRegistry pkg_session_telemetry --> svc_telemetry pkg_session_telemetry_otel --> svc_telemetry pkg_session_title --> svc_sessionTitle @@ -279,6 +285,7 @@ flowchart LR svc_sessionQuery --> pkg_session_reference svc_sessionQuery --> pkg_tool_session_query svc_sessionReferences --> pkg_tui + svc_sessionRegistry --> pkg_session_registry_live svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop svc_sessions --> pkg_cli_demo @@ -339,6 +346,7 @@ flowchart LR | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | | `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. | +| `ctx.sessionRegistry` | `seam` | [`session-registry`](../packages/session-registry/session-registry) | [`session-registry-file`](../packages/session-registry/session-registry-file) | [`session-registry-live`](../packages/session-registry/session-registry-live) | - | Seam contract for live-session records; the file backend owns the lock-guarded medium, liveness is derived from the recorded pid at read time, and the publisher mirrors lifecycle and title events for `dsh list-sessions`. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. | | `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | [`tui`](../packages/ui/tui) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session-title/session-title) | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3c0543e4ca..8bd6e966b9 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1153,6 +1153,26 @@ export interface Config { Source: [`packages/context/session-reference/src/config.ts:11`](../packages/context/session-reference/src/config.ts) +## `@deepseek-ai/dsh-session-registry-file` + +```ts config-catalog +/** + * Plugin config as callers write it: `root` is required — a cwd fallback would + * scatter registries — while the lock tunables are optional because + * `static Config` supplies their defaults. + */ +export interface Config { + /** Directory holding the registry file; created `0o700` on demand. */ + root: string + /** Milliseconds after which a held lock is considered abandoned and reclaimed. */ + lockStaleMs?: number + /** Retries before a contended acquisition fails loud. */ + lockRetries?: number +} +``` + +Source: [`packages/session-registry/session-registry-file/src/index.ts:43`](../packages/session-registry/session-registry-file/src/index.ts) + ## `@deepseek-ai/dsh-session-telemetry-otel` Requires: `sessions` @@ -1263,6 +1283,38 @@ export interface Config { Source: [`packages/skill/skill-local/src/index.ts:41`](../packages/skill/skill-local/src/index.ts) +## `@deepseek-ai/dsh-source-guard` + +Requires: `fs` + +```ts config-catalog +/** + * Plugin config, validated by the same-named schemastery schema plus the + * load-time checks in `apply` (misconfiguration fails loud: an empty `tools` + * list, a blank `requiredSkill`, or a relative `protectedCheckout` throws at + * plugin load, never a silent fall-back). + */ +export interface Config { + /** Skill whose loaded presence in the session lifts the denial (default `dsh-customize`). */ + requiredSkill?: string + /** Tool names to gate (default `['write', 'edit']`). */ + tools?: string[] + /** + * Absolute path inside the checkout this guard protects. Its worktree + * supplies BOTH protected identities: the repository (targets in any other + * repository are ignored) and the exact branch (only that branch's worktree + * is protected). Defaults to this module's own location, which resolves the + * checkout the running harness was launched from — the live deployment, + * whatever its branch is named. Set it explicitly to guard a different + * checkout, or when the harness runs from an installed copy whose own + * location is not a checkout at all. + */ + protectedCheckout?: string +} +``` + +Source: [`packages/guard/source-guard/src/index.ts:30`](../packages/guard/source-guard/src/index.ts) + ## `@deepseek-ai/dsh-spill-local` ```ts config-catalog @@ -1541,6 +1593,20 @@ export interface Config { Source: [`packages/context/time-context/src/index.ts:20`](../packages/context/time-context/src/index.ts) +## `@deepseek-ai/dsh-tmux-context` + +Requires: `agents` + +```ts config-catalog +/** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ +export interface Config { + /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible change. */ + refreshIntervalMs?: number +} +``` + +Source: [`packages/context/tmux-context/src/index.ts:33`](../packages/context/tmux-context/src/index.ts) + ## `@deepseek-ai/dsh-token-meter` ```ts config-catalog @@ -1960,7 +2026,12 @@ export interface Config { dshHome?: string /** Fallback session-title limits forwarded through agent-spine-demo. */ sessionTitle?: NonNullable - /** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */ + /** + * Directory for JSONL sessions and the derived query index. Precedence: + * this explicit config, then the launcher's opaque `SESSIONS_ROOT_KEY` boot + * slot (the dsh CLI resolves it to `DSH_HOME/sessions`), then a project-local + * `./.sessions` fallback — the bundle itself never assumes a global store. + */ persistenceRoot?: string /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ persistenceCompression?: JsonlCompression @@ -1968,13 +2039,6 @@ export interface Config { 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. */ @@ -1985,8 +2049,6 @@ export interface Config { toolTasks?: NonNullable /** 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'] } @@ -1994,7 +2056,7 @@ export interface Config { Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`SessionReferenceConfig`](#deepseek-aidsh-session-reference) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) -Source: [`packages/examples/tui-demo/src/index.ts:39`](../packages/examples/tui-demo/src/index.ts) +Source: [`packages/examples/tui-demo/src/index.ts:44`](../packages/examples/tui-demo/src/index.ts) ## `@deepseek-ai/dsh-user-approval` @@ -2231,6 +2293,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-session-projection` ([`packages/session-projection/session-projection/src/index.ts`](../packages/session-projection/session-projection/src/index.ts)) +- `@deepseek-ai/dsh-session-registry-live` — requires `sessions` · `sessionRegistry` ([`packages/session-registry/session-registry-live/src/index.ts`](../packages/session-registry/session-registry-live/src/index.ts)) - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts)) @@ -2253,6 +2316,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) - `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts)) +- `@deepseek-ai/dsh-session-registry` — abstract `SessionRegistry` ([`packages/session-registry/session-registry/src/index.ts`](../packages/session-registry/session-registry/src/index.ts)) - `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts)) - `@deepseek-ai/dsh-subprocess` — abstract `SubprocessService` ([`packages/subprocess/subprocess/src/index.ts`](../packages/subprocess/subprocess/src/index.ts)) - `@deepseek-ai/dsh-tasks` — abstract `TaskService` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index db31daaf06..7010113be7 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1464,6 +1464,44 @@ Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-s Source: [`packages/context/session-reference/src/index.ts:70`](../../packages/context/session-reference/src/index.ts) +## `ctx.sessionRegistry` — `SessionRegistry` (abstract seam) + +Cross-process live-session registry. Reads prune dead records, so every returned record's process existed at observation time. Backends serialize mutations against concurrent registrars — other processes and overlapping calls in this one — so records are never lost to a torn read-modify-write. + +```ts cordis-catalog +/** + * Publish this process's record, replacing any stale record for the same + * session id, and prune records whose process is gone. + * @param registration - the session, surface, and workspace to publish. + * @returns the effect disposer that removes this record again; awaiting it + * waits for the removal to reach durability. + */ +abstract register(registration: SessionRegistration): Promise<() => Promise> + +/** + * Replace the recorded title of a session this process registered. + * + * Titles arrive after registration and can be revised, so this is the one + * mutable field. Only a record matching this process and incarnation is + * touched, leaving a same-id record owned by another process alone. An unknown + * session id is a no-op rather than an error: a title can resolve after the + * session's record has already been removed. + * @param sessionId - the session whose recorded title changes. + * @param title - the new title text. + */ +abstract retitle(sessionId: SessionId, title: string): Promise + +/** + * List live sessions, pruning records whose process no longer exists. + * @returns one record per live registered session, newest registration last. + */ +abstract list(): Promise +``` + +Types: [SessionId](../core-data-structures/core.md) + +Source: [`packages/session-registry/session-registry/src/index.ts:44`](../../packages/session-registry/session-registry/src/index.ts) + ## `ctx.sessions` — `SessionStore` In-memory session store (`ctx.sessions`). diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index 5072c2684d..74392c18ef 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -116,6 +116,7 @@ | extension | 扩展 | | | | | extension point | 扩展点 | | | 注意与 `seam` 区分 | | fail-fast | 快速失败 | | | | +| fail-open | 故障放行 | 故障放行(fail-open) | 故障开放 | 与 `fail-fast` 对称;指无法判定时放行而非阻断 | | fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 | | fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash | | finish reason | 结束原因 | | | | diff --git a/docs/module-graph.md b/docs/module-graph.md index d3e60978e7..777457020d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -173,6 +173,7 @@ flowchart TD subgraph group_context["packages/context"] pkg_session_reference["session-reference"] pkg_time_context["time-context"] + pkg_tmux_context["tmux-context"] pkg_workspace_context["workspace-context"] end subgraph group_examples["packages/examples"] @@ -184,6 +185,7 @@ flowchart TD end subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] + pkg_source_guard["source-guard"] end subgraph group_host["packages/host"] pkg_host_apiproxy["host-apiproxy"] @@ -221,6 +223,11 @@ flowchart TD pkg_session_projection["session-projection"] pkg_session_projection_cache["session-projection-cache"] end + subgraph group_session_registry["packages/session-registry"] + pkg_session_registry["session-registry"] + pkg_session_registry_file["session-registry-file"] + pkg_session_registry_live["session-registry-live"] + end subgraph group_storage["packages/storage"] pkg_storage["storage"] pkg_storage_domain["storage-domain"] @@ -450,6 +457,9 @@ flowchart TD pkg_sandbox_policy --> pkg_session pkg_session_projection --> pkg_invariants pkg_session_projection --> pkg_session + pkg_session_registry --> pkg_brand + pkg_session_registry --> pkg_invariants + pkg_session_registry --> pkg_session pkg_llm_retry --> pkg_agent pkg_llm_retry --> pkg_invariants pkg_llm_retry --> pkg_llm @@ -523,6 +533,10 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session + pkg_tmux_context --> pkg_agent + pkg_tmux_context --> pkg_bash + pkg_tmux_context --> pkg_invariants + pkg_tmux_context --> pkg_session pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants @@ -533,6 +547,9 @@ flowchart TD pkg_session_projection_cache --> pkg_session_persistence pkg_session_projection_cache --> pkg_session_projection pkg_session_projection_cache --> pkg_storage_domain + pkg_session_registry_file --> pkg_invariants + pkg_session_registry_file --> pkg_session + pkg_session_registry_file --> pkg_session_registry pkg_tasks --> pkg_agent pkg_tasks --> pkg_brand pkg_tasks --> pkg_invariants @@ -614,6 +631,10 @@ flowchart TD pkg_pty_local --> pkg_sandbox_policy pkg_pty_local --> pkg_session pkg_pty_local --> pkg_subprocess + pkg_session_registry_live --> pkg_invariants + pkg_session_registry_live --> pkg_session + pkg_session_registry_live --> pkg_session_registry + pkg_session_registry_live --> pkg_session_title pkg_tasks_local --> pkg_agent pkg_tasks_local --> pkg_invariants pkg_tasks_local --> pkg_tasks @@ -776,6 +797,13 @@ flowchart TD pkg_repeat_tool_guard --> pkg_agent pkg_repeat_tool_guard --> pkg_invariants pkg_repeat_tool_guard --> pkg_tools + pkg_source_guard --> pkg_agent + pkg_source_guard --> pkg_fs + pkg_source_guard --> pkg_invariants + pkg_source_guard --> pkg_llm + pkg_source_guard --> pkg_sandbox + pkg_source_guard --> pkg_session + pkg_source_guard --> pkg_tools pkg_tool_lsp --> pkg_invariants pkg_tool_lsp --> pkg_llm pkg_tool_lsp --> pkg_lsp @@ -1045,6 +1073,7 @@ flowchart TD | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`session-registry`](../packages/session-registry/session-registry) | `session-registry` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1062,9 +1091,11 @@ flowchart TD | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`storage-domain`](../packages/storage/storage-domain) | +| [`session-registry-file`](../packages/session-registry/session-registry-file) | `session-registry` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-registry`](../packages/session-registry/session-registry) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-telemetry`](../packages/telemetry/session-telemetry) | `telemetry` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1080,6 +1111,7 @@ flowchart TD | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | +| [`session-registry-live`](../packages/session-registry/session-registry-live) | `session-registry` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-registry`](../packages/session-registry/session-registry), [`session-title`](../packages/session-title/session-title) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1107,6 +1139,7 @@ flowchart TD | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | +| [`source-guard`](../packages/guard/source-guard) | `guard` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml index 83144b2153..601ac94419 100644 --- a/examples/cordis-agent/cordis.yml +++ b/examples/cordis-agent/cordis.yml @@ -60,7 +60,6 @@ config: provider: deepseek model: deepseek-v4-pro - resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined" persistenceRoot: './.sessions' workspaceContext: maxBytes: 65536 diff --git a/examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml b/examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml new file mode 100644 index 0000000000..a8a83302bb --- /dev/null +++ b/examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml @@ -0,0 +1,38 @@ +# Test-only composition: the model attempts one `write` into a staging-shaped +# git fixture, so the guard's denial is observed through the real Loader and app. +- id: source-guard-mock-llm + name: './mock-llm.ts' + +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: fs + name: '@deepseek-ai/dsh-fs-local' + +# Read-before-edit policy: without it the write would resolve `createIfAbsent` +# and the transcript would not show the guard as the sole reason for refusal. +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +# Mounts the guard with `protectedCheckout` resolved against the process cwd, so +# it arms for the staging fixture the smoke builds there rather than for the +# checkout running the test (the config default is this module's own location). +- id: source-guard-fixture + name: './mount-guard.ts' + +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + provider: source-guard-mock + model: source-guard-mock + persona: 'Test the source guard.' + persistenceRoot: './.sessions' + persistenceCompression: none + workspaceContext: false diff --git a/examples/headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts b/examples/headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts new file mode 100644 index 0000000000..44baccd617 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts @@ -0,0 +1,43 @@ +import { resolve } from 'node:path' +import type { Context } from 'cordis' +import { CallId, LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' + +/** The staged file the smoke builds in the process cwd; the guard must refuse to write it. */ +const TARGET = resolve('staging/guarded.ts') + +/** + * Two-step adapter for the source-guard Loader fixture: the first step calls + * `write` on the staged file, the second closes the turn once a tool result has + * come back, so the transcript records what the model received. + */ +class SourceGuardMockAdapter extends LlmAdapter { + async * stream(options: GenerateOptions): AsyncIterable { + const alreadyCalled = options.messages.some(message => message.content.some( + block => block.type === 'tool-result', + )) + if (alreadyCalled) { + const text = 'denied as expected' + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text } + yield { type: 'block-end', index: 0, block: { type: 'text', text } } + yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } } + yield { type: 'finish', reason: { kind: 'stop' } } + return + } + const callId = CallId('source-guard-write') + const args = JSON.stringify({ file_path: TARGET, content: 'edited\n' }) + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { type: 'tool-call-delta', index: 0, id: callId, name: 'write', argumentsDelta: args } + yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: callId, name: 'write', arguments: args } } + yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } + } +} + +export const name = 'source-guard-mock-llm' +export const inject = ['llm'] + +/** Register the test-only `source-guard-mock` adapter. */ +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['source-guard-mock'], new SourceGuardMockAdapter()) +} diff --git a/examples/headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts b/examples/headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts new file mode 100644 index 0000000000..90a89a2897 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts @@ -0,0 +1,14 @@ +import { resolve } from 'node:path' +import type { Context } from 'cordis' +import * as SourceGuard from '@deepseek-ai/dsh-source-guard' + +export const name = 'source-guard-fixture' + +/** + * Mount the real guard against the staging fixture in the process cwd. The + * checkout under protection is a runtime fact of the isolated smoke directory, + * which no static config value can name. + */ +export async function apply(ctx: Context): Promise { + await ctx.plugin(SourceGuard, { protectedCheckout: resolve('staging/guard-anchor.ts') }) +} diff --git a/examples/headless-agent/tests/fixtures/tmux-context-driver.ts b/examples/headless-agent/tests/fixtures/tmux-context-driver.ts new file mode 100644 index 0000000000..2fd6d0f5ec --- /dev/null +++ b/examples/headless-agent/tests/fixtures/tmux-context-driver.ts @@ -0,0 +1,16 @@ +#!/usr/bin/env node +/** Test driver that sends two turns through one Headless Loader composition. */ + +import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts' + +const configPath = process.argv[2] +if (configPath === undefined) throw new Error('tmux-context driver requires a config path') + +const ctx = await boot('tmux-context-e2e', resolveConfigPath(configPath, undefined)) +try { + await runOneShot(ctx, { task: 'first' }) + await runOneShot(ctx, { task: 'second' }) +} finally { + await ctx.fiber.dispose() +} diff --git a/examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts b/examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts new file mode 100644 index 0000000000..3e9540abff --- /dev/null +++ b/examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts @@ -0,0 +1,46 @@ +import type { Context } from 'cordis' +import { BashExecutor } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' + +/** + * Deterministic `ctx.bash` for the tmux-context Loader fixture: any command + * (the plugin's `tmux display-message`) returns a fixed tab-delimited reading, + * so the injected tmux location is stable without a real tmux server. `start()` + * throws — tmux-context must never spawn a background process. + */ +class TmuxMockBash extends BashExecutor { + override resolve(request: BashExecRequest): BashExecSpec { + return { + command: request.command, + workdir: request.workdir ?? process.cwd(), + timeoutMs: request.timeoutMs ?? 60_000, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, + signal: request.signal, + sandboxPolicy: request.sandboxPolicy, + } + } + + override run(_spec: BashExecSpec): Promise { + const line = ['work', '0', 'editor', '1', '%3', '1', '1', 'a1b2,80x24,0,0,4'].join('\\t') + return Promise.resolve({ + exitCode: 0, + signal: null, + timedOut: false, + aborted: false, + timeoutMs: 60_000, + stdout: { text: `${line}\n`, truncated: false }, + stderr: { text: '', truncated: false }, + }) + } + + override start(): BashProcess { + throw new Error('tmux-context must never start a background task') + } +} + +export const name = 'tmux-context-mock-bash' + +/** Register the deterministic `ctx.bash` executor for the fixture. */ +export function apply(ctx: Context): void { + ctx.plugin(TmuxMockBash) +} diff --git a/examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts b/examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts new file mode 100644 index 0000000000..2f6c7a6408 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts @@ -0,0 +1,22 @@ +import type { Context } from 'cordis' +import { LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm' + +/** Deterministic one-step adapter for the tmux-context Loader fixture. */ +class TmuxContextMockAdapter extends LlmAdapter { + async * stream(): AsyncIterable { + const text = 'tmux context sampled' + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text } + yield { type: 'block-end', index: 0, block: { type: 'text', text } } + yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +export const name = 'tmux-context-mock-llm' +export const inject = ['llm'] + +/** Register the test-only `tmux-context-mock` adapter. */ +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['tmux-context-mock'], new TmuxContextMockAdapter()) +} diff --git a/examples/headless-agent/tests/fixtures/tmux-context.cordis.yml b/examples/headless-agent/tests/fixtures/tmux-context.cordis.yml new file mode 100644 index 0000000000..419922a0e3 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/tmux-context.cordis.yml @@ -0,0 +1,21 @@ +# Test-only composition: keep tmux-context opt-in while exercising its real Loader/app path. +# A deterministic mock ctx.bash returns a fixed tmux reading, so the injected location +# is stable without a real tmux server on the test host. +- id: tmux-context-mock-llm + name: './tmux-context-mock-llm.ts' + +- id: bash + name: './tmux-context-mock-bash.ts' + +- id: tmux-context + name: '@deepseek-ai/dsh-tmux-context' + +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + provider: tmux-context-mock + model: tmux-context-mock + persona: 'Test the tmux-context plugin.' + persistenceRoot: './.sessions' + persistenceCompression: 'none' + workspaceContext: false diff --git a/examples/package.json b/examples/package.json index 51fc48b8fa..b6f06057e3 100644 --- a/examples/package.json +++ b/examples/package.json @@ -10,6 +10,7 @@ "@deepseek-ai/dsh-acp-demo": "workspace:*", "@deepseek-ai/dsh-agent-spine-demo": "workspace:*", "@deepseek-ai/dsh-app-boot": "workspace:*", + "@deepseek-ai/dsh-bash": "workspace:*", "@deepseek-ai/dsh-bash-local": "workspace:*", "@deepseek-ai/dsh-bash-sandbox": "workspace:*", "@deepseek-ai/dsh-cli-demo": "workspace:*", @@ -44,6 +45,7 @@ "@deepseek-ai/dsh-session-query-sqlite": "workspace:*", "@deepseek-ai/dsh-session-telemetry-otel": "workspace:*", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:*", + "@deepseek-ai/dsh-source-guard": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", @@ -54,6 +56,7 @@ "@deepseek-ai/dsh-tasks-local": "workspace:*", "@deepseek-ai/dsh-time-context": "workspace:*", "@deepseek-ai/dsh-timeout-policy": "workspace:*", + "@deepseek-ai/dsh-tmux-context": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-tool-ask-user": "workspace:*", "@deepseek-ai/dsh-tool-cordis": "workspace:*", diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index ea8695d37e..eae5994fd8 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -29,7 +29,7 @@ Each run starts a fresh session by default (its event log lands under `./.sessio dsh --resume ``` -`/resume` opens a searchable keyboard selector with titles, activity, last-turn results, model route, durable goal phase, and live/persisted state. The installed `dsh` host flushes and disposes the current app, then replaces the process with `dsh --resume `. The TUI still prints that command on exit and shows it when a custom host cannot hand off. `dsh --resume ` provides the id on the boot context, which `cordis.yml` reads (`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`); with no flag the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. The selector has no cross-process session lock, so deployments with concurrent hosts must coordinate session ownership separately. +`/resume` opens a searchable keyboard selector with titles, activity, last-turn results, model route, durable goal phase, and live/persisted state. The installed `dsh` host flushes and disposes the current app, then replaces the process with `dsh --resume `; a host that cannot hand off in place says so and leaves the session running. Resume needs no key in this file: `dsh` provides the session identity and the exit line on the boot context, so `--resume ` and the printed resume command survive any personal-overlay patch of the `tui-agent` entry. With no flag the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. The selector has no cross-process session lock, so deployments with concurrent hosts must coordinate session ownership separately. ## Code Mode diff --git a/examples/tui-agent/code-mode.cordis.yml b/examples/tui-agent/code-mode.cordis.yml index 46d982794c..eec54fe6ad 100644 --- a/examples/tui-agent/code-mode.cordis.yml +++ b/examples/tui-agent/code-mode.cordis.yml @@ -10,9 +10,7 @@ config: provider: deepseek model: deepseek-v4-pro - resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined" persistenceRoot: './.sessions' - resumeCommand: 'dsh --resume {session}' workspaceContext: maxBytes: 65536 tools: diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index 86129d3fa4..1bd1b7298c 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -59,6 +59,8 @@ flowchart LR cfg --> plugin_tui_fs_policy plugin_tui_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] cfg --> plugin_tui_tool_fs + plugin_tui_source_guard["source-guard
@deepseek-ai/dsh-source-guard"] + cfg --> plugin_tui_source_guard plugin_tui_tool_fs_search["tool-fs-search
@deepseek-ai/dsh-tool-fs-search"] cfg --> plugin_tui_tool_fs_search plugin_tui_timeout_policy["timeout-policy
@deepseek-ai/dsh-timeout-policy"] @@ -93,6 +95,7 @@ flowchart LR | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `fs-policy` | `@deepseek-ai/dsh-fs-policy` | | `tool-fs` | `@deepseek-ai/dsh-tool-fs` | +| `source-guard` | `@deepseek-ai/dsh-source-guard` | | `tool-fs-search` | `@deepseek-ai/dsh-tool-fs-search` | | `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` | | `spill-local` | `@deepseek-ai/dsh-spill-local` | diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 2b14271c96..b3f31ee928 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -47,15 +47,11 @@ config: provider: deepseek model: deepseek-v4-pro - # `dsh --resume ` provides the session id on the boot context (the ids - # live under ./.sessions); with no flag the identifier is undefined and a - # fresh session starts each run. The typeof guard tolerates a launcher that - # never provides the slot, reading undefined rather than throwing. - resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined" - persistenceRoot: './.sessions' - # Printed on exit and listed by `/resume`; `{session}` fills the live id. - # `dsh --resume ` resumes that session, so run it from this cwd. - resumeCommand: 'dsh --resume {session}' + # Session identity and the resume command printed on exit are launcher-owned: + # `dsh` provides both on the boot context, so `--resume ` and the exit + # hint need no key here. `persistenceRoot` is omitted: the dsh launcher + # supplies its shared Harness-home store through a boot slot, and a bare + # example boot falls back to the bundle's project-local `./.sessions`. workspaceContext: maxBytes: 65536 ui: @@ -169,6 +165,15 @@ - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' +# Refuses write/edit inside the dsh checkout this launcher runs from, on that +# checkout's own branch, until the session loads dsh-customize — the skill whose +# workflow (task worktree, then integrate under the staging lock) the refusal +# points at. Inert everywhere else: another repository, a task worktree nested +# under the protected one, a sibling checkout on a different branch, and any +# workspace outside a dsh source install all pass through untouched. +- id: source-guard + name: '@deepseek-ai/dsh-source-guard' + # Bash-backed discovery tools (glob/grep): fixed ripgrep commands through the # local bash executor above — not ctx.fs. Capped results save the complete # formatted list through the spill backend below (ctx.spillStore, optional). diff --git a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts index 517fa6adab..79c4f0504a 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts +++ b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts @@ -20,6 +20,13 @@ const SKILL_BLOCK_OPEN = '' const SKILL_BODY_MARKER = 'SCRIPTED SKILL BODY MARKER' const SKILL_RECEIVED_TEXT = 'Scripted skill body received.' const TITLE_TEXT = 'scripted session title' +// The failing-bash scenario proves the terminal card reports a non-zero exit +// exactly once: the model-facing result carries the `[exit code: N]` marker, and +// the card turns it into its own `[exit N]` pill instead of showing both. +const BASH_FAILURE_PROBE = 'Run the failing scripted command.' +const BASH_FAILURE_COMMAND = 'printf "SCRIPTED_BASH_FAILED\\n"; exit 3' +const BASH_FAILURE_TEXT = 'Scripted bash failure observed.' +const BASH_FAILURE_CALL_ID = CallId('call-bash-failure') function textChunks(text: string): StreamChunk[] { return [ @@ -108,9 +115,23 @@ class ScriptedTuiAdapter extends LlmAdapter { return } - const hasToolResult = lastMessage?.content.some(block => block.type === 'tool-result') ?? false - if (hasToolResult) { - for (const chunk of textChunks(FINAL_TEXT)) yield chunk + const blocks = lastMessage?.content ?? [] + if (blocks.some(block => block.type === 'tool-result')) { + const answered = blocks.some(block => block.type === 'tool-result' && block.toolCallId === BASH_FAILURE_CALL_ID) + for (const chunk of textChunks(answered ? BASH_FAILURE_TEXT : FINAL_TEXT)) yield chunk + return + } + if (lastText.includes(BASH_FAILURE_PROBE)) { + const bashArgs = JSON.stringify({ command: BASH_FAILURE_COMMAND, description: 'Run the failing scripted command' }) + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { type: 'tool-call-delta', index: 0, id: BASH_FAILURE_CALL_ID, name: 'bash', argumentsDelta: bashArgs } + yield { + type: 'block-end', + index: 0, + block: { type: 'tool-call', id: BASH_FAILURE_CALL_ID, name: 'bash', arguments: bashArgs }, + } + yield { type: 'usage', usage: { inputTokens: 20, outputTokens: 10 } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } return } diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml index a32bdf987b..e615a7ec09 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml +++ b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml @@ -33,8 +33,6 @@ # The smoke's log inspection reads plain `.jsonl`; keep the scripted # fixture uncompressed like the other snapshot-facing configs. persistenceCompression: none - resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined" - resumeCommand: 'dsh --resume {session}' workspaceContext: maxBytes: 65536 welcome: 'scripted TUI ready.' diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 7ae98167de..5b1ccdff75 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -365,11 +365,11 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('must be a top-level YAML array of loader patch entries') }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('routes the --resume flag into the config resume intake, failing loud on a missing id', async () => { - // The flag path end to end: apps/cli parses `--resume missing-session` and - // provides the id on the boot context, the shipped config's `!!js` reads it - // as a bare identifier, and the resume fails loud — proving the printed - // `dsh --resume ` hint reaches the config resume intake with no env var. + it('routes the --resume flag into the launcher session-identity slot, failing loud on a missing id', async () => { + // The flag path end to end: apps/cli parses `--resume missing-session`, + // provides it as the launcher-owned identity on the boot context, and the + // resume fails loud — proving the printed hint reaches the app's resume + // intake with no config key and no environment variable. const output = await smoke({ label: 'dsh resume flag failure', tempDirPrefix: 'dsh-resume-flag-', @@ -380,6 +380,70 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('ui-tui: session "missing-session" failed to start:') }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('prints the launcher-owned resume command on exit, naming the booted config', async () => { + // The exit line is built by apps/cli from this invocation, so it must carry + // `--config`: a hint that omitted it would resume into the default tree. + const output = await smoke({ + label: 'dsh goodbye message', + tempDirPrefix: 'dsh-goodbye-', + binScript: dshBinScript, + configPath: scriptedConfigPath, + actions: [{ waitFor: 'scripted TUI ready.', send: '/exit\r' }], + }) + expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36} --config/) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('keeps resume working when the personal overlay replaces the whole tui-agent config', async () => { + // Loader patches replace a targeted `config` key wholesale, so a personal + // overlay that omits a resume key used to silently disable the exit hint. + // Launcher-owned identity and exit line make that unreachable. + const output = await smoke({ + label: 'dsh overlay keeps resume', + tempDirPrefix: 'dsh-overlay-resume-', + binScript: dshBinScript, + configArgs: [], + prepare: seedWorkspace({ + personal: { + 'config.yaml': [ + '- id: tui-agent', + " name: '@deepseek-ai/dsh-tui-demo'", + ' config:', + ' provider: deepseek', + ' model: deepseek-v4-flash', + ' workspaceContext: false', + ' welcome: OVERLAY REPLACED THE CONFIG.', + '', + ].join('\n'), + }, + }), + actions: [{ waitFor: 'OVERLAY REPLACED THE CONFIG.', send: '/exit\r' }], + }) + expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36}/) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('reports a failing bash command exactly once, as the terminal card exit pill', async () => { + // The model-facing result ends in `[exit code: 3]`, which the terminal card + // consumes into its own `[exit 3]` pill. Rendering both would report the same + // exit twice, so the marker must not survive into the card body. + const output = await smoke({ + label: 'tui-agent bash exit pill', + tempDirPrefix: 'dsh-bash-exit-pill-', + configPath: scriptedConfigPath, + actions: [ + ...SELECT_PRO_MODEL, + { + waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', + send: 'Run the failing scripted command.\r', + }, + { waitFor: 'Scripted bash failure observed.', send: '/exit\r' }, + ], + }) + // The command really ran: its stdout is in the card body. + expect(output).toContain('SCRIPTED_BASH_FAILED') + expect(output).toContain('[exit 3]') + expect(output).not.toContain('[exit code: 3]') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('tells the model its source path and offers the bundled maintenance skills', async () => { // The launcher resolves the checkout root three hops up from apps/cli/{src,lib}; // this test file sits an equal depth under the same root, so the same hop applies. diff --git a/knip.json b/knip.json index 38eb32c223..0d616c8388 100644 --- a/knip.json +++ b/knip.json @@ -33,10 +33,15 @@ "headless-agent/tests/fixtures/semantic-checkpoint-agent.ts", "headless-agent/tests/fixtures/subagent-inheritance-agent.ts", "headless-agent/tests/fixtures/goal-domain/seed-goal.ts", + "headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts", + "headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts", "headless-agent/tests/fixtures/time-context-driver.ts", "headless-agent/tests/fixtures/time-context-mock-llm.ts", "headless-agent/tests/fixtures/telemetry-otel-driver.ts", "headless-agent/tests/fixtures/telemetry-redact-rule.ts", + "headless-agent/tests/fixtures/tmux-context-driver.ts", + "headless-agent/tests/fixtures/tmux-context-mock-llm.ts", + "headless-agent/tests/fixtures/tmux-context-mock-bash.ts", "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", @@ -174,6 +179,16 @@ "tests/**/*.ts" ] }, + "packages/context/tmux-context": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/lsp/lsp-local": { "entry": [ "tests/**/*.spec.ts", @@ -289,6 +304,26 @@ "tests/**/*.ts" ] }, + "packages/guard/source-guard": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, + "packages/session-registry/session-registry-file": { + "entry": [ + "tests/**/*.spec.ts", + "tests/fixtures/register-once.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/session-query/session-query-sqlite": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/context/README.md b/packages/context/README.md index a5244dfe99..ffe9aaa9e2 100644 --- a/packages/context/README.md +++ b/packages/context/README.md @@ -2,12 +2,13 @@ English | [中文](README.zh.md) -Product plugins that add model-visible request context without defining a tool. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context` is opt-in, while the standard TUI bundle composes `session-reference` explicitly. +Product plugins that add model-visible request context without defining a tool. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context` and `tmux-context` are opt-in, while the standard TUI bundle composes `session-reference` explicitly. | Package | Role | ctx key | |---|---|---| | `session-reference/` | Bounded current-surface snapshots of other sessions | `ctx.sessionReferences` | | `time-context/` | Durable per-step current time and elapsed-time context | (none) | -| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/step` + `tools/post-execute`) | +| `tmux-context/` | Durable per-turn context with this agent's tmux pane/window location | (listens on `agent/pre-step`, reads `ctx.bash`) | +| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/session-prefix` + `tools/post-execute`) | The [`workspace-context` decision record](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) explains its per-agent/session isolation and lifecycle split. diff --git a/packages/context/tmux-context/README.i18n.yaml b/packages/context/tmux-context/README.i18n.yaml new file mode 100644 index 0000000000..ec4562eea4 --- /dev/null +++ b/packages/context/tmux-context/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/context/tmux-context/README.md +README.md: 5ea36948d6d83135c5aa97650c0d77e942adbbaa +README.zh.md: 914d8d7c99c37de2c64541bcf4968996d819077d diff --git a/packages/context/tmux-context/README.md b/packages/context/tmux-context/README.md new file mode 100644 index 0000000000..5ea36948d6 --- /dev/null +++ b/packages/context/tmux-context/README.md @@ -0,0 +1,68 @@ +# @deepseek-ai/dsh-tmux-context + +English | [中文](README.zh.md) + +Opt-in durable context naming the tmux session, window, and pane this agent process runs in, plus the window's pane-tree layout. Sampled once per turn during model-request preparation. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md). + +## Config + +```yaml +- id: tmux-context + name: '@deepseek-ai/dsh-tmux-context' + config: + refreshIntervalMs: 60000 # optional; omit or set to 0 to inject on every changed turn +``` + +`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` injects whenever the tmux state changed since the last injection. A positive value additionally suppresses injections that fall within that many milliseconds of the latest one. + +## How it reads tmux + +The plugin prepends an `agent/step` listener that runs only on the first step of each turn. When due, it runs one read-only command through the `ctx.bash` executor seam: + +```sh +[ -n "$TMUX_PANE" ] || exit 1 +self_tty=$(ps -o tty= -p | tr -d ' ') +pane_tty=$(tmux display-message -t "$TMUX_PANE" -p '#{pane_tty}') || exit 1 +[ "$pane_tty" = "/dev/$self_tty" ] || exit 1 +exec tmux display-message -t "$TMUX_PANE" -p '' +``` + +`$TMUX_PANE` alone is insufficient: a terminal launched from a tmux shell (a VS Code integrated terminal, a desktop launcher) **inherits** `$TMUX` and `$TMUX_PANE` from that ancestor, so the variables are present even though the process does not live in that pane. The command therefore also compares the pane's `#{pane_tty}` against this process's own controlling terminal (`ps -o tty=` for its pid): a genuine pane owns this process's tty, while an inherited environment names some other pane's tty. Running through `ctx.bash` applies the deployment's sandbox and policy; the plugin owns no subprocess code. When `ctx.bash` is absent, the process is not in a real tmux pane (`$TMUX_PANE` unset, or the tty does not match ⇒ nonzero exit), or the reading is malformed, the attempt is a no-op, never an error. + +State is pulled on every eligible turn — a moved, renamed, or re-laid-out pane is picked up without any tmux hook or background process. The plugin re-injects only when the rendered tmux state differs from its last injection, so an unchanged location adds nothing. + +## Timing semantics + +When an injection is due, the plugin appends one injected `user/message` through `agent.inject()` before `step/start`, with source `{ kind: 'plugin', plugin: 'tmux-context' }`. Change suppression and interval scheduling scan the raw durable session events for the latest injection of this source, so the schedule survives compaction and resumed processes without process-local cache state; sessions schedule independently. The reading records a request-preparation attempt, not a committed step; because the listener runs first, its append may remain when a later pre-step listener cancels or fails the attempt (the log is append-only and the plugin performs no rollback). + +## Model Experience + +### Preparation-time tmux location + +#### What the model sees + +On each turn whose tmux state changed, one source-tagged context message with the three lines below. `` is tmux's compact pane-tree description; pane and window pixel sizes are intentionally excluded, and the contents of sibling panes are never captured. + +##### Changed-turn reading + +```markdown +tmux location (turn ): +session , window "", pane +window active=<0|1>, pane active=<0|1>, layout +``` + +#### Token effect + +Each two-line reading accumulates until compaction shadows it. Unchanged locations and interval suppression add nothing. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + +## Known Limitations and Deferred Work + +- **First step only** — a pane moved or resized mid-turn is reflected on the next turn, not between steps. +- **Own location only** — the plugin never captures the visible text of sibling panes. +- **Layout, not size** — pane/window pixel dimensions are omitted; only the layout tree and active flags are reported. +- **Tab-delimited fields** — a tmux window name containing the literal two-character sequence `\t` would mis-split the reading and be skipped as malformed; ordinary names are unaffected. +- **tty-based pane detection** — the process is considered "in tmux" only when its controlling terminal matches `$TMUX_PANE`'s `#{pane_tty}`. This deliberately excludes terminals that inherited `$TMUX`/`$TMUX_PANE` from a tmux ancestor (e.g. a VS Code integrated terminal). `ps -o tty=` is POSIX; the check is a no-op wherever it or `#{pane_tty}` is unavailable. diff --git a/packages/context/tmux-context/README.zh.md b/packages/context/tmux-context/README.zh.md new file mode 100644 index 0000000000..914d8d7c99 --- /dev/null +++ b/packages/context/tmux-context/README.zh.md @@ -0,0 +1,68 @@ +# @deepseek-ai/dsh-tmux-context + +[English](README.md) | 中文 + +可选启用的持久上下文,记录本 agent 进程所在的 tmux session、window、pane,以及该 window 的 pane 树布局。在准备模型请求时每轮采样一次。`dsh-agent-spine-demo` 与随附示例均不挂载它。决策记录见:[tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md)。 + +## 配置 + +```yaml +- id: tmux-context + name: '@deepseek-ai/dsh-tmux-context' + config: + refreshIntervalMs: 60000 # optional; omit or set to 0 to inject on every changed turn +``` + +`refreshIntervalMs` 必须是非负安全整数。省略或 `0` 表示只要 tmux 状态自上次注入以来发生变化就注入。正值会额外抑制距最近一次注入不足该毫秒数的注入。 + +## 如何读取 tmux + +插件前置注册一个 `agent/step` 监听器,仅在每轮的第一个 step 运行。当需要注入时,它通过 `ctx.bash` 执行器 seam 运行一条只读命令: + +```sh +[ -n "$TMUX_PANE" ] || exit 1 +self_tty=$(ps -o tty= -p | tr -d ' ') +pane_tty=$(tmux display-message -t "$TMUX_PANE" -p '#{pane_tty}') || exit 1 +[ "$pane_tty" = "/dev/$self_tty" ] || exit 1 +exec tmux display-message -t "$TMUX_PANE" -p '' +``` + +仅凭 `$TMUX_PANE` 并不足够:从 tmux shell 启动的终端(VS Code 集成终端、桌面启动器)会从该祖先进程**继承** `$TMUX` 与 `$TMUX_PANE`,因此即使进程并不位于那个 pane 中,这些变量依然存在。为此该命令还会把 pane 的 `#{pane_tty}` 与本进程自己的控制终端(对其 pid 执行 `ps -o tty=`)作比较:真正的 pane 拥有本进程的 tty,而继承而来的环境指向的是另一个 pane 的 tty。通过 `ctx.bash` 运行会应用部署方的沙箱与策略;插件不拥有任何子进程代码。当 `ctx.bash` 缺失、进程不在真实的 tmux pane 内(`$TMUX_PANE` 未设置,或 tty 不匹配 ⇒ 非零退出)或读取结果格式非法时,本次尝试为空操作,绝不报错。 + +状态在每个符合条件的轮次拉取——pane 被移动、改名或重新布局都会被感知,无需任何 tmux hook 或后台进程。插件仅在渲染出的 tmux 状态与上次注入不同时才重新注入,因此位置不变时不会新增任何内容。 + +## 时序语义 + +当需要注入时,插件在 `step/start` 之前通过 `agent.inject()` 追加一条注入的 `user/message`,来源为 `{ kind: 'plugin', plugin: 'tmux-context' }`。变化抑制与间隔调度会扫描原始持久会话事件中该来源的最近一次注入,因此调度可跨压缩与恢复的进程存续,无需进程内缓存状态;各会话独立调度。该读数记录的是一次请求准备尝试,而非已提交的 step;由于监听器最先运行,当后续 pre-step 监听器取消或失败时,它的追加可能仍会保留(日志只追加,插件不做回滚)。 + +## 模型体验 + +### 准备期 tmux 位置 + +#### 模型看到的内容 + +在 tmux 状态发生变化的每一轮,注入一条带来源标记、含以下三行的上下文消息。`` 是 tmux 紧凑的 pane 树描述;pane 与 window 的像素尺寸有意省略,相邻 pane 的内容从不采集。 + +##### 变化轮次读数 + +```markdown +tmux location (turn ): +session , window "", pane +window active=<0|1>, pane active=<0|1>, layout +``` + +#### Token 影响 + +每条两行读数会累积,直到压缩将其遮蔽。位置未变化以及间隔抑制不会新增内容。 + +#### KV 缓存影响 + +只追加;新增可见内容位于可复用的请求前缀之后,不会使已有 KV 缓存条目失效。 + +## 已知限制与后续工作 + +- **仅第一个 step**——轮次中途移动或缩放的 pane 会在下一轮反映,而非在 step 之间。 +- **仅自身位置**——插件从不采集相邻 pane 的可见文本。 +- **只有布局,没有尺寸**——省略 pane/window 像素尺寸;仅报告布局树与活动标志。 +- **制表符分隔字段**——若 tmux window 名称包含字面两字符序列 `\t`,会使读数分割错误并作为非法读数跳过;常规名称不受影响。 +- **基于 tty 的 pane 判定**——只有当进程的控制终端与 `$TMUX_PANE` 的 `#{pane_tty}` 一致时,才视为“位于 tmux 中”。这会有意排除从 tmux 祖先进程继承 `$TMUX`/`$TMUX_PANE` 的终端(如 VS Code 集成终端)。`ps -o tty=` 属于 POSIX;在其或 `#{pane_tty}` 不可用的环境中,该检查即为空操作。 diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json new file mode 100644 index 0000000000..a874ebb08c --- /dev/null +++ b/packages/context/tmux-context/package.json @@ -0,0 +1,49 @@ +{ + "name": "@deepseek-ai/dsh-tmux-context", + "description": "Opt-in durable per-step context with this agent's tmux pane and window location", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "schemastery": "^3.18.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts new file mode 100644 index 0000000000..495ab886a0 --- /dev/null +++ b/packages/context/tmux-context/src/index.ts @@ -0,0 +1,227 @@ +/** + * Opt-in request-preparation tmux-location context. Eligible step attempts + * append durable, source-attributed context naming the tmux session, window, + * and pane this agent process runs in, plus the window's pane-tree layout. + * + * The plugin pulls state once per turn, on the first step (`step === 1`), by + * running one `tmux display-message` through the `ctx.bash` executor seam. It + * confirms this process genuinely runs inside the pane `$TMUX_PANE` names by + * matching the pane's `#{pane_tty}` against this process's controlling terminal, + * so a terminal that merely inherited `$TMUX`/`$TMUX_PANE` from a tmux ancestor + * (e.g. a VS Code integrated terminal) reads as "not in tmux". It re-injects + * only when the rendered tmux state changes since the last injection (a moved, + * renamed, or re-laid-out pane), with an optional `refreshIntervalMs` floor + * between injections. Absent tmux environment, an inherited-only environment, + * absent `ctx.bash`, or a failed query is a no-op, never an error. + * + * @module @deepseek-ai/dsh-tmux-context + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { BashExecutor } from '@deepseek-ai/dsh-bash' +import { createUserMessage } from '@deepseek-ai/dsh-llm' + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'tmux-context' + +/** The agent registry that owns the `agent/step` lifecycle seam. */ +export const inject = ['agents'] + +/** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ +export interface Config { + /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible change. */ + refreshIntervalMs?: number +} + +/** Schemastery validation for {@link Config}. */ +export const Config: z = z.object({ + refreshIntervalMs: z.number(), +}) + +/** + * Tab-separated tmux format fields, in query order. Layout (`window_layout`) + * is the pane-tree description; pane/window pixel sizes are intentionally + * excluded (own location and layout only, per the package scope). + */ +const TMUX_FIELDS = [ + '#{session_name}', + '#{window_index}', + '#{window_name}', + '#{pane_index}', + '#{pane_id}', + '#{window_active}', + '#{pane_active}', + '#{window_layout}', +] as const + +/** Structured tmux location parsed from one `display-message` reading. */ +interface TmuxLocation { + sessionName: string + windowIndex: string + windowName: string + paneIndex: string + paneId: string + windowActive: string + paneActive: string + windowLayout: string +} + +/** Prefix marking the volatile turn/step preamble line of a rendered reading. */ +const READING_PREFIX = 'tmux location (turn ' + +/** + * Field separator between tmux format fields. tmux does not interpret C escapes + * in a format, so the literal two-character sequence `\t` is emitted verbatim + * and split back out here; this avoids embedding raw whitespace in the command. + */ +const FIELD_SEP = '\\t' + +/** + * Read this process's tmux location through the bash seam, or `undefined` when + * this process is not genuinely running inside a tmux pane or the query fails. + * + * `$TMUX_PANE` alone is insufficient: a terminal launched from a tmux shell + * (e.g. VS Code's integrated terminal, a desktop launcher) inherits `$TMUX` and + * `$TMUX_PANE` from that ancestor, so the variables are present even though this + * process does not live in that pane. The command therefore also compares the + * pane's `#{pane_tty}` against this process's own controlling terminal + * (`ps -o tty=` for {@link processId}); a genuine pane owns this process's tty, + * an inherited environment names some other pane's tty. Fields are emitted only + * on a match, so an inherited environment reads as "not in tmux" and injects + * nothing. + * + * @param bash - the executor seam used to run the read-only tmux/ps commands. + * @param processId - this agent process's pid, whose controlling tty must match the pane. + * @param signal - abort signal forwarded to the executor. + * @returns the parsed location, or `undefined` when not in a real pane or on any failure. + */ +async function queryTmuxLocation( + bash: BashExecutor, + processId: number, + signal: AbortSignal, +): Promise { + const format = TMUX_FIELDS.join(FIELD_SEP) + const command = [ + '[ -n "$TMUX_PANE" ] || exit 1', + `self_tty=$(ps -o tty= -p ${processId} | tr -d ' ')`, + '[ -n "$self_tty" ] || exit 1', + 'pane_tty=$(tmux display-message -t "$TMUX_PANE" -p \'#{pane_tty}\') || exit 1', + '[ "$pane_tty" = "/dev/$self_tty" ] || exit 1', + `exec tmux display-message -t "$TMUX_PANE" -p '${format}'`, + ].join('\n') + const spec = bash.resolve({ command, signal }) + const result = await bash.run(spec) + if (result.exitCode !== 0) return undefined + const line = result.stdout.text.split('\n', 1)[0] as string + const parts = line.split(FIELD_SEP) + if (parts.length !== TMUX_FIELDS.length) return undefined + const [ + sessionName, + windowIndex, + windowName, + paneIndex, + paneId, + windowActive, + paneActive, + windowLayout, + ] = parts as [string, string, string, string, string, string, string, string] + if (paneId.length === 0) return undefined + return { + sessionName, + windowIndex, + windowName, + paneIndex, + paneId, + windowActive, + paneActive, + windowLayout, + } +} + +/** + * Render the stable tmux state block: the part of a reading compared for + * change suppression. It excludes the turn preamble so re-injection is driven + * only by tmux state, not by loop position. + */ +function renderState(location: TmuxLocation): string { + return `session ${location.sessionName}, ` + + `window ${location.windowIndex} ${JSON.stringify(location.windowName)}, ` + + `pane ${location.paneIndex} ${location.paneId}\n` + + `window active=${location.windowActive}, pane active=${location.paneActive}, ` + + `layout ${location.windowLayout}` +} + +/** Render the full durable reading, including the volatile turn preamble. */ +function renderReading(location: TmuxLocation, turn: number): string { + return `${READING_PREFIX}${turn}):\n${renderState(location)}` +} + +/** + * The stable state block of this plugin's latest durable injection, or + * `undefined` when the session has none. Scans raw durable events so the + * schedule survives compaction and resumed processes without process-local + * cache state. + */ +function latestInjectedState(agent: Agent): { state: string; time: number } | undefined { + for (const event of [...agent.session.events].reverse()) { + if (event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === name) { + const [block] = event.data.content + if (block?.type !== 'text') return undefined + const newline = block.text.indexOf('\n') + const state = newline === -1 ? '' : block.text.slice(newline + 1) + return { state, time: event.time } + } + } + return undefined +} + +/** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */ +function validateRefreshInterval(refreshIntervalMs: number | undefined): void { + if (refreshIntervalMs !== undefined && ( + !Number.isSafeInteger(refreshIntervalMs) + || refreshIntervalMs < 0 + )) { + throw new TypeError( + `tmux-context: refreshIntervalMs must be a non-negative safe integer, got ${String(refreshIntervalMs)}`, + ) + } +} + +/** + * Register a prepended `agent/step` listener for the lifetime of `ctx`. + * @param ctx - plugin context; the listener is disposed with it. + * @param config - durable refresh scheduling configuration. + * @throws when the refresh interval is invalid. + */ +export function apply(ctx: Context, config: Config): void { + const refreshIntervalMs = config.refreshIntervalMs + validateRefreshInterval(refreshIntervalMs) + + ctx.on('agent/step', async ( + agent: Agent, + turn: number, + step: number, + signal: AbortSignal, + ): Promise => { + if (signal.aborted || step !== 1) return + const bash = ctx.get('bash') + if (bash === undefined) return + const previous = latestInjectedState(agent) + if (refreshIntervalMs !== undefined && refreshIntervalMs > 0 && previous !== undefined) { + const now = Date.now() + if (now >= previous.time && now - previous.time < refreshIntervalMs) return + } + const location = await queryTmuxLocation(bash, process.pid, signal) + if (location === undefined) return + const state = renderState(location) + if (previous !== undefined && previous.state === state) return + agent.inject(createUserMessage({ + content: [{ type: 'text', text: renderReading(location, turn) }], + source: { kind: 'plugin', plugin: name }, + })) + }, { prepend: true }) +} diff --git a/packages/context/tmux-context/src/invariant.ts b/packages/context/tmux-context/src/invariant.ts new file mode 100644 index 0000000000..181f1a2289 --- /dev/null +++ b/packages/context/tmux-context/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tmux-context`. + * @module @deepseek-ai/dsh-tmux-context/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tmux-context' + +/** Cordis companion plugin name. */ +export const name = 'tmux-context-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: a reading is a per-turn snapshot of external tmux state, so the session + * holds no cross-event relation to check; scheduling and format are owned by pipeline tests. + */ +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 */ diff --git a/packages/context/tmux-context/tests/tmux-context.e2e.ts b/packages/context/tmux-context/tests/tmux-context.e2e.ts new file mode 100644 index 0000000000..06e3c1f3f5 --- /dev/null +++ b/packages/context/tmux-context/tests/tmux-context.e2e.ts @@ -0,0 +1,77 @@ +import { readFile, readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { type SessionEvent } from '@deepseek-ai/dsh-session' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' + +// Keep the Loader config under examples so both modes exercise the same deployable +// topology: local fixture source plus bare plugins owned by the examples workspace. +const driver = fileURLToPath(new URL( + '../../../../examples/headless-agent/tests/fixtures/tmux-context-driver.ts', + import.meta.url, +)) +const configPath = fileURLToPath(new URL( + '../../../../examples/headless-agent/tests/fixtures/tmux-context.cordis.yml', + import.meta.url, +)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +async function jsonlFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const paths = await Promise.all(entries.map(async (entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return jsonlFiles(path) + return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] + })) + return paths.flat() +} + +describe('tmux-context through a real headless cordis.yml', () => { + it('injects one ordered tmux-location event on the first turn and suppresses the unchanged second', async () => { + let events: SessionEvent[] = [] + const { stderr } = await runLoaderSmoke({ + label: 'tmux-context headless smoke', + tempDirPrefix: 'tmux-context-e2e-', + binScript: driver, + libBinScript: driver, + configPath, + tsconfigPath: repoTsconfig, + inspect: async (cwd) => { + const logs = await jsonlFiles(join(cwd, '.sessions')) + expect(logs).toHaveLength(1) + const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') + events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) + }, + }) + expect(stderr).not.toContain('UNHANDLED') + expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2) + + const contexts = events.filter( + (event): event is SessionEvent<'user/message'> => + event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === 'tmux-context') + // Two identical-state turns: the location injects once and is suppressed after. + expect(contexts).toHaveLength(1) + + const [reading] = contexts + if (reading === undefined) throw new Error('missing tmux-context reading') + const starts = events.filter(event => event.type === 'step/start') + expect(reading.seq).toBeLessThan(starts[0]!.seq) + expect(reading.surfaceOp).toBe('append') + + const text = reading.data.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('\n') + expect(text).toBe( + 'tmux location (turn 1):\n' + + 'session work, window 0 "editor", pane 1 %3\n' + + 'window active=1, pane active=1, layout a1b2,80x24,0,0,4', + ) + + const headers = events.filter(event => event.type === 'request/header') + expect(JSON.stringify(headers)).not.toContain('tmux location (turn') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts new file mode 100644 index 0000000000..fedd672f57 --- /dev/null +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -0,0 +1,367 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { BashExecutor } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' +import * as tmuxContext from '@deepseek-ai/dsh-tmux-context' +import type { Config } from '@deepseek-ai/dsh-tmux-context' + +const SIGNAL = new AbortController().signal + +/** One `#{...}`-joined tmux reading line for the eight queried fields. */ +function tmuxLine(fields: { + sessionName?: string + windowIndex?: string + windowName?: string + paneIndex?: string + paneId?: string + windowActive?: string + paneActive?: string + windowLayout?: string +} = {}): string { + return [ + fields.sessionName ?? '0', + fields.windowIndex ?? '1', + fields.windowName ?? 'node', + fields.paneIndex ?? '2', + fields.paneId ?? '%90', + fields.windowActive ?? '1', + fields.paneActive ?? '0', + fields.windowLayout ?? 'd517,270x71,0,0{135x71,0,0,87,134x71,136,0[134x35,136,0,90,134x35,136,36,93]}', + ].join('\\t') +} + +function runResult(stdout: string, overrides: Partial = {}): BashRunResult { + return { + exitCode: 0, + signal: null, + timedOut: false, + aborted: false, + timeoutMs: 60_000, + stdout: { text: stdout, truncated: false }, + stderr: { text: '', truncated: false }, + ...overrides, + } +} + +/** A scriptable fake `ctx.bash` recording the command it was asked to run. */ +class FakeBash extends BashExecutor { + commands: string[] = [] + result: BashRunResult = runResult(`${tmuxLine()}\n`) + runError?: Error + + override resolve(request: BashExecRequest): BashExecSpec { + return { + command: request.command, + workdir: request.workdir ?? '/work', + timeoutMs: request.timeoutMs ?? 60_000, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, + signal: request.signal, + sandboxPolicy: request.sandboxPolicy, + } + } + override async run(spec: BashExecSpec): Promise { + this.commands.push(spec.command) + if (this.runError) throw this.runError + return this.result + } + override start(): BashProcess { + throw new Error('tmux-context must never start a background task') + } +} + +async function mount(config: Config, withBash: true): Promise<{ ctx: Context; bash: FakeBash }> +async function mount(config?: Config, withBash?: boolean): Promise<{ ctx: Context; bash: FakeBash | undefined }> +async function mount( + config: Config = {}, + withBash = false, +): Promise<{ ctx: Context; bash: FakeBash | undefined }> { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + let bash: FakeBash | undefined + if (withBash) { + await ctx.plugin(FakeBash) + bash = ctx.bash as FakeBash + } + await ctx.plugin(tmuxContext, config) + return { ctx, bash } +} + +function sessionAgent(session: Session, id = 'agent'): Agent { + return { + id: SessionId(id), + options: {}, + session, + status: 'running', + acceptsNextStep: true, + ctx: new Context(), + followup: () => {}, + steer: () => {}, + inject(input) { + session.append('user/message', input, { surfaceOp: 'append' }) + }, + send: () => {}, + cancel() {}, + whenIdle: () => Promise.resolve(), + } +} + +function openMessageTurn(session: Session, turn: number): void { + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: `turn ${turn}` }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) +} + +function contextTexts(session: Session): string[] { + const texts: string[] = [] + for (const event of session.events) { + if (event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === 'tmux-context') { + texts.push(event.data.content.find(block => block.type === 'text')?.text ?? '') + } + } + return texts +} + +async function fire( + ctx: Context, + agent: Agent, + turn: number, + step: number, + signal: AbortSignal = SIGNAL, +): Promise { + await agentEvents(ctx, agent).serial('agent/step', turn, step, signal) +} + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +describe('tmux-context injection', () => { + it('injects the tmux location on the first step of a turn', async () => { + const { ctx } = await mount({}, true) + const session = new Session(SessionId('first')) + openMessageTurn(session, 1) + + await fire(ctx, sessionAgent(session), 1, 1) + + expect(contextTexts(session)).toEqual([ + 'tmux location (turn 1):\n' + + 'session 0, window 1 "node", pane 2 %90\n' + + 'window active=1, pane active=0, ' + + 'layout d517,270x71,0,0{135x71,0,0,87,134x71,136,0[134x35,136,0,90,134x35,136,36,93]}', + ]) + const event = session.events.at(-1) + if (event?.type !== 'user/message') throw new Error('missing tmux context') + expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'tmux-context' }) + expect(event.surfaceOp).toBe('append') + }) + + it('queries the pane this process runs in and matches its controlling tty', async () => { + const { ctx, bash } = await mount({}, true) + const session = new Session(SessionId('command')) + openMessageTurn(session, 1) + + await fire(ctx, sessionAgent(session), 1, 1) + + expect(bash.commands).toHaveLength(1) + const command = bash.commands[0]! + expect(command).toContain('[ -n "$TMUX_PANE" ]') + expect(command).toContain('tmux display-message -t "$TMUX_PANE" -p') + // Guards against an inherited $TMUX_PANE: the pane's tty must equal this + // process's controlling tty (resolved for this exact pid). + expect(command).toContain(`ps -o tty= -p ${process.pid}`) + // The exact fragment matters: unquoted, `#` starts a shell comment and the + // substitution silently breaks while a substring check still passes. + expect(command).toContain('pane_tty=$(tmux display-message -t "$TMUX_PANE" -p \'#{pane_tty}\') || exit 1') + expect(command).toContain('[ "$pane_tty" = "/dev/$self_tty" ]') + }) + + it('does not run on later steps of a turn', async () => { + const { ctx, bash } = await mount({}, true) + const session = new Session(SessionId('later-step')) + openMessageTurn(session, 1) + + await fire(ctx, sessionAgent(session), 1, 2) + + expect(bash.commands).toHaveLength(0) + expect(contextTexts(session)).toHaveLength(0) + }) + + it('re-injects a new turn only when tmux state changed', async () => { + const { ctx, bash } = await mount({}, true) + const session = new Session(SessionId('change')) + const agent = sessionAgent(session) + + openMessageTurn(session, 1) + await fire(ctx, agent, 1, 1) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + // Same state on turn 2: suppressed. + openMessageTurn(session, 2) + await fire(ctx, agent, 2, 1) + session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + expect(contextTexts(session)).toHaveLength(1) + + // Moved pane on turn 3: re-injected. + bash.result = runResult(`${tmuxLine({ windowName: 'shell', paneId: '%12' })}\n`) + openMessageTurn(session, 3) + await fire(ctx, agent, 3, 1) + + const texts = contextTexts(session) + expect(texts).toHaveLength(2) + expect(texts[1]).toContain('tmux location (turn 3):') + expect(texts[1]).toContain('window 1 "shell", pane 2 %12') + }) + + it('honors a positive refresh interval between injections', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const { ctx, bash } = await mount({ refreshIntervalMs: 10_000 }, true) + const session = new Session(SessionId('interval')) + const agent = sessionAgent(session) + + openMessageTurn(session, 1) + await fire(ctx, agent, 1, 1) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + // Changed state but inside the interval: suppressed, and never queried. + bash.result = runResult(`${tmuxLine({ paneId: '%99' })}\n`) + vi.setSystemTime(5_000) + openMessageTurn(session, 2) + await fire(ctx, agent, 2, 1) + expect(contextTexts(session)).toHaveLength(1) + expect(bash.commands).toHaveLength(1) + + // Past the interval: queried and re-injected. + vi.setSystemTime(12_000) + openMessageTurn(session, 3) + await fire(ctx, agent, 3, 1) + expect(contextTexts(session)).toHaveLength(2) + expect(bash.commands).toHaveLength(2) + }) +}) + +describe('tmux-context prior-reading resilience', () => { + it('treats a prior non-text plugin reading as absent and injects afresh', async () => { + const { ctx, bash } = await mount({}, true) + const session = new Session(SessionId('prior-non-text')) + const agent = sessionAgent(session) + openMessageTurn(session, 1) + session.append('user/message', createUserMessage({ + content: [{ type: 'reasoning', text: 'not a location' }], + source: { kind: 'plugin', plugin: 'tmux-context' }, + }), { surfaceOp: 'append' }) + + await fire(ctx, agent, 1, 1) + + expect(bash.commands).toHaveLength(1) + expect(contextTexts(session).at(-1)).toContain('tmux location (turn 1):') + }) + + it('treats a prior single-line plugin reading (no newline) as empty state', async () => { + const { ctx, bash } = await mount({}, true) + const session = new Session(SessionId('prior-single-line')) + const agent = sessionAgent(session) + openMessageTurn(session, 1) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'single line, no newline' }], + source: { kind: 'plugin', plugin: 'tmux-context' }, + }), { surfaceOp: 'append' }) + + await fire(ctx, agent, 1, 1) + + // Empty prior state never equals the multi-line reading, so it re-injects. + expect(bash.commands).toHaveLength(1) + expect(contextTexts(session).at(-1)).toContain('tmux location (turn 1):') + }) +}) + +describe('tmux-context no-op paths', () => { + it('is a no-op when no bash executor is mounted', async () => { + const { ctx } = await mount() + const session = new Session(SessionId('no-bash')) + openMessageTurn(session, 1) + + await fire(ctx, sessionAgent(session), 1, 1) + + expect(contextTexts(session)).toHaveLength(0) + }) + + it('is a no-op when the tmux query exits nonzero (outside tmux, or an inherited env whose tty does not match the pane)', async () => { + const { ctx, bash } = await mount({}, true) + bash.result = runResult('', { exitCode: 1 }) + const session = new Session(SessionId('outside-tmux')) + openMessageTurn(session, 1) + + await fire(ctx, sessionAgent(session), 1, 1) + + expect(contextTexts(session)).toHaveLength(0) + }) + + it('is a no-op when the reading has the wrong field count', async () => { + const { ctx, bash } = await mount({}, true) + bash.result = runResult('0\\t1\\tnode\n') + const session = new Session(SessionId('malformed')) + openMessageTurn(session, 1) + + await fire(ctx, sessionAgent(session), 1, 1) + + expect(contextTexts(session)).toHaveLength(0) + }) + + it('is a no-op when the pane id is empty', async () => { + const { ctx, bash } = await mount({}, true) + bash.result = runResult(`${tmuxLine({ paneId: '' })}\n`) + const session = new Session(SessionId('empty-pane')) + openMessageTurn(session, 1) + + await fire(ctx, sessionAgent(session), 1, 1) + + expect(contextTexts(session)).toHaveLength(0) + }) + + it('skips an already-aborted step and runs before ordinary agent/step listeners', async () => { + const { ctx } = await mount({}, true) + const session = new Session(SessionId('ordering')) + const agent = sessionAgent(session) + openMessageTurn(session, 1) + let ordinarySawContext = false + ctx.on('agent/step', (subject) => { + ordinarySawContext = subject.session.events.some( + event => event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === 'tmux-context', + ) + }) + + const abort = new AbortController() + abort.abort() + await fire(ctx, agent, 1, 1, abort.signal) + expect(contextTexts(session)).toHaveLength(0) + + await fire(ctx, agent, 1, 1) + expect(ordinarySawContext).toBe(true) + expect(contextTexts(session)).toHaveLength(1) + }) +}) + +describe('tmux-context configuration', () => { + it('rejects a negative refresh interval at plugin load', async () => { + await expect(mount({ refreshIntervalMs: -1 })).rejects.toThrow( + /refreshIntervalMs must be a non-negative safe integer/, + ) + }) + + it('rejects a non-integer refresh interval at plugin load', async () => { + await expect(mount({ refreshIntervalMs: 1.5 })).rejects.toThrow( + /refreshIntervalMs must be a non-negative safe integer/, + ) + }) +}) diff --git a/packages/context/tmux-context/tsconfig.json b/packages/context/tmux-context/tsconfig.json new file mode 100644 index 0000000000..fe893f9402 --- /dev/null +++ b/packages/context/tmux-context/tsconfig.json @@ -0,0 +1,40 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../bash/bash" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../support/loader-smoke" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../../core/session" + } + ] +} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 7d9b087b8f..a27c126b75 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -688,6 +688,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'sessionRegistry', + summary: 'Cross-process live-session registry.', + methods: [ + { + signature: 'abstract register(registration: SessionRegistration): Promise<() => Promise>', + jsDoc: '/**\n * Publish this process\'s record, replacing any stale record for the same\n * session id, and prune records whose process is gone.\n * @param registration - the session, surface, and workspace to publish.\n * @returns the effect disposer that removes this record again; awaiting it\n * waits for the removal to reach durability.\n */', + }, + { + signature: 'abstract retitle(sessionId: SessionId, title: string): Promise', + jsDoc: '/**\n * Replace the recorded title of a session this process registered.\n *\n * Titles arrive after registration and can be revised, so this is the one\n * mutable field. Only a record matching this process and incarnation is\n * touched, leaving a same-id record owned by another process alone. An unknown\n * session id is a no-op rather than an error: a title can resolve after the\n * session\'s record has already been removed.\n * @param sessionId - the session whose recorded title changes.\n * @param title - the new title text.\n */', + }, + { + signature: 'abstract list(): Promise', + jsDoc: '/**\n * List live sessions, pruning records whose process no longer exists.\n * @returns one record per live registered session, newest registration last.\n */', + }, + ], + }, { key: 'sessions', summary: 'In-memory session store (`ctx.sessions`).', @@ -1543,6 +1561,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'BashSandboxInfo', declaration: 'export interface BashSandboxInfo {\n mode: SandboxMode;\n denied: boolean;\n enforcement?: SandboxEnforcement;\n runnerFailed?: boolean;\n}', }, + { + name: 'BootId', + declaration: 'export type BootId = Branded<\'BootId\'>;', + }, { name: 'Branded', declaration: 'export type Branded = string & {\n readonly [BRAND]: B;\n};', @@ -2247,6 +2269,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionReferenceInput', declaration: 'export interface SessionReferenceInput {\n sessionId: SessionId;\n label?: string;\n}', }, + { + name: 'SessionRegistration', + declaration: 'export interface SessionRegistration {\n sessionId: SessionId;\n cwd: string;\n title?: string;\n}', + }, + { + name: 'SessionRegistryRecord', + declaration: 'export interface SessionRegistryRecord {\n readonly sessionId: SessionId;\n readonly pid: number;\n readonly cwd: string;\n readonly startedAt: number;\n readonly bootId: BootId;\n readonly title?: string;\n}', + }, { name: 'SessionResultFilter', declaration: 'export type SessionResultFilter = {\n kind: \'id\';\n values: readonly SessionId[];\n} | {\n kind: \'cwd\';\n values: readonly (string | null)[];\n} | ({\n kind: \'created-at\';\n} & SessionResultRange) | {\n kind: \'parent\';\n values: readonly (SessionId | null)[];\n} | {\n kind: \'availability\';\n values: readonly SessionAvailability[];\n};', diff --git a/packages/examples/tui-demo/README.md b/packages/examples/tui-demo/README.md index 058ebe87af..cbe8c79780 100644 --- a/packages/examples/tui-demo/README.md +++ b/packages/examples/tui-demo/README.md @@ -39,15 +39,15 @@ Swappable LLM, bash, filesystem, and other capability providers remain in the le | `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 | +| `persistenceRoot` | `./.sessions` (launcher boot slot overrides) | 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-` 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. +Session identity is launcher-owned rather than configurable: a launcher provides `MAIN_SESSION_ID_KEY` on the boot context, and this app binds both the TUI and the configured agent to that id, loading persisted history only when the launcher also set `resume`. With no such slot the app mints a `main-session-` and creates it fresh. 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`; a launcher may additionally provide `tuiResumeHost` for in-place process handoff and `TUI_GOODBYE_MESSAGE_KEY` for the line printed on exit. + +`persistenceRoot` defaults to project-local `./.sessions`: an app bundle must not assume the user's shared session store. A launcher that wants one store across every cwd states that policy through the `SESSIONS_ROOT_KEY` boot slot (`ctx.provide` before any Loader entry mounts) — the dsh CLI provides its Harness-home root there, so its `/resume` lists sessions from every workspace. Precedence is explicit config, then the launcher slot, then the project-local default. ## Front door diff --git a/packages/examples/tui-demo/package.json b/packages/examples/tui-demo/package.json index 50145e6c29..fc59453f0e 100644 --- a/packages/examples/tui-demo/package.json +++ b/packages/examples/tui-demo/package.json @@ -30,20 +30,20 @@ "@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-command-goal": "^0.0.1", + "@deepseek-ai/dsh-commands": "^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-persistence-jsonl": "^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-tui": "^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", @@ -53,21 +53,21 @@ "@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-command-goal": "workspace:^", + "@deepseek-ai/dsh-commands": "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-persistence-jsonl": "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-tui": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", "cordis": "^4.0.0-rc.7", diff --git a/packages/examples/tui-demo/src/index.ts b/packages/examples/tui-demo/src/index.ts index c60ba94b3c..df644090c8 100644 --- a/packages/examples/tui-demo/src/index.ts +++ b/packages/examples/tui-demo/src/index.ts @@ -30,6 +30,11 @@ import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as uiTui from '@deepseek-ai/dsh-tui' export const name = 'tui-demo' + +// The bundle's own fallback stays project-local: a plugin must never assume +// the user's shared session store. The dsh launcher's SESSIONS_ROOT_KEY slot +// (opaque here — the CLI resolves it to DSH_HOME/sessions) carries any +// shared-store policy, and explicit config wins over both. const DEFAULT_PERSISTENCE_ROOT = './.sessions' // Each front door keeps a complete Loader contract so its deployment config is @@ -53,7 +58,12 @@ export interface Config { dshHome?: string /** Fallback session-title limits forwarded through agent-spine-demo. */ sessionTitle?: NonNullable - /** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */ + /** + * Directory for JSONL sessions and the derived query index. Precedence: + * this explicit config, then the launcher's opaque `SESSIONS_ROOT_KEY` boot + * slot (the dsh CLI resolves it to `DSH_HOME/sessions`), then a project-local + * `./.sessions` fallback — the bundle itself never assumes a global store. + */ persistenceRoot?: string /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ persistenceCompression?: JsonlCompression @@ -61,13 +71,6 @@ export interface Config { 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. */ @@ -78,8 +81,6 @@ export interface Config { toolTasks?: NonNullable /** 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'] } @@ -94,33 +95,38 @@ export const Config: z = z.object({ tools: ToolRegistry.Config, dshHome: z.string(), sessionTitle: agentCore.SessionTitleConfigSchema, - persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT), + // No schema default: schemastery would materialize it before composeTuiApp + // runs, shadowing the launcher's SESSIONS_ROOT_KEY slot for a Loader mount. + persistenceRoot: z.string(), 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. + * exact fresh or resumed session identity, taken from the launcher's + * {@link uiTui.MAIN_SESSION_ID_KEY} slot. 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()}`) + // The launcher, not the deployment config, owns `main`'s session identity: it + // reaches a Loader-mounted bundle only through this context slot. A launcher + // that supplies an id knows whether that session already exists, so it also + // states whether to load persisted history. No launcher means mint one here. + const identity = ctx.get(uiTui.MAIN_SESSION_ID_KEY) + const sessionId = SessionId(identity?.id ?? `main-session-${randomUUID()}`) const goals = config.goals ?? {} - const persistenceRoot = config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT + const persistenceRoot = config.persistenceRoot ?? ctx.get(uiTui.SESSIONS_ROOT_KEY) ?? DEFAULT_PERSISTENCE_ROOT ctx.plugin(CommandService) if (goals !== false) ctx.plugin(commandGoal) ctx.plugin(SessionPersistenceJsonl, { @@ -135,7 +141,6 @@ export function composeTuiApp(ctx: Context, config: Config): void { ctx.plugin(uiTui, { ...config.ui, ...config.welcome === undefined ? {} : { welcome: config.welcome }, - ...config.resumeCommand === undefined ? {} : { resumeCommand: config.resumeCommand }, sessionId, }) ctx.plugin(agentCore, { @@ -146,7 +151,9 @@ export function composeTuiApp(ctx: Context, config: Config): void { provider: config.provider, model: config.model, cwd: process.cwd(), - ...resumeSessionId === undefined ? { sessionId } : { resumeSessionId: sessionId }, + // `resumeSessionId` requires existing persisted history and rejects a + // missing log, so only a launcher that asked to resume takes that path. + ...identity?.resume === true ? { resumeSessionId: sessionId } : { sessionId }, }], }) ctx.plugin(toolAskUser) diff --git a/packages/examples/tui-demo/tests/tui-agent.spec.ts b/packages/examples/tui-demo/tests/tui-agent.spec.ts index f647b3d9c6..88519e33a1 100644 --- a/packages/examples/tui-demo/tests/tui-agent.spec.ts +++ b/packages/examples/tui-demo/tests/tui-agent.spec.ts @@ -3,6 +3,8 @@ 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 { SessionId } from '@deepseek-ai/dsh-session' +import { MAIN_SESSION_ID_KEY, SESSIONS_ROOT_KEY, type MainSessionIdentity } from '@deepseek-ai/dsh-tui' import * as tuiAgent from '../src/index.ts' interface PluginCall { @@ -10,12 +12,21 @@ interface PluginCall { readonly config: unknown } -function recordingContext(): { readonly ctx: Context; readonly calls: PluginCall[] } { +/** + * Record the composed plugin tree. `identity` stands in for the launcher-owned + * {@link MAIN_SESSION_ID_KEY} slot; omitting it means no launcher chose a session. + */ +function recordingContext( + identity?: MainSessionIdentity, + sessionsRoot?: string, +): { readonly ctx: Context; readonly calls: PluginCall[] } { const calls: PluginCall[] = [] const ctx = { plugin(plugin: { name?: string }, config?: unknown) { calls.push({ name: plugin.name ?? '', config }) }, + get: (key: string) => key === MAIN_SESSION_ID_KEY ? identity + : key === SESSIONS_ROOT_KEY ? sessionsRoot : undefined, } as unknown as Context return { ctx, calls } } @@ -39,7 +50,6 @@ describe('dsh-tui-demo app', () => { maxReferenceBytes: 1234, }, welcome: 'TUI ready', - resumeCommand: 'dsh --resume {session}', ui: { theme: { color: false }, maxToolOutputLines: 3 }, skills: { tool: { catalogDescriptionMaxLength: 8 } }, toolBash: { enableRunInBackground: false }, @@ -71,7 +81,6 @@ describe('dsh-tui-demo app', () => { const tuiConfig = calls[8]?.config as { sessionId: string } expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', - resumeCommand: 'dsh --resume {session}', theme: { color: false }, maxToolOutputLines: 3, }) @@ -100,16 +109,46 @@ describe('dsh-tui-demo app', () => { }) }) - it('resumes the configured session and applies runtime defaults', () => { - const { ctx, calls } = recordingContext() + it('uses the launcher sessions-root slot through schema-normalized config', () => { + // The Loader normalizes config through the schemastery Config BEFORE apply + // runs. A schema .default() on persistenceRoot would materialize here and + // permanently shadow the launcher slot — the regression this test pins. + const normalized = tuiAgent.Config({ + provider: 'mock', + model: 'mock-model', + workspaceContext: false, + } as never) + expect(normalized.persistenceRoot).toBeUndefined() + + const { ctx, calls } = recordingContext(undefined, '/launcher/sessions') + tuiAgent.composeTuiApp(ctx, normalized) + expect(calls[2]?.config).toMatchObject({ root: '/launcher/sessions' }) + expect(calls[4]?.config).toEqual({ path: join('/launcher/sessions', 'session-query.db') }) + }) + + it('lets an explicit persistenceRoot win over the launcher slot', () => { + const { ctx, calls } = recordingContext(undefined, '/launcher/sessions') + tuiAgent.composeTuiApp(ctx, { + provider: 'mock', + model: 'mock-model', + persistenceRoot: '/explicit/root', + workspaceContext: false, + }) + expect(calls[2]?.config).toEqual({ root: '/explicit/root' }) + }) + + it('loads persisted history for a launcher-selected resume identity', () => { + // The bundle default stays project-local: shared-store policy is the + // launcher's, which patches `persistenceRoot` itself (the dsh CLI does). + const { ctx, calls } = recordingContext({ id: SessionId('persisted-session'), resume: true }) tuiAgent.composeTuiApp(ctx, { provider: 'mock', model: 'mock-model', - resumeSessionId: 'persisted-session', workspaceContext: false, }) expect(calls[2]?.config).toEqual({ root: './.sessions' }) + expect(calls[4]?.config).toEqual({ path: join('./.sessions', 'session-query.db') }) 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' }) @@ -119,12 +158,24 @@ describe('dsh-tui-demo app', () => { }) }) - it('normalizes an empty resume id and routes apply through the same composition', () => { + it('creates a launcher-minted identity fresh rather than loading history', () => { + const { ctx, calls } = recordingContext({ id: SessionId('minted-session'), resume: false }) + tuiAgent.composeTuiApp(ctx, { + provider: 'mock', + model: 'mock-model', + workspaceContext: false, + }) + + expect(calls[8]?.config).toEqual({ sessionId: 'minted-session' }) + expect((calls[9]?.config as { agents: Array> }).agents[0]) + .toMatchObject({ id: 'main', sessionId: 'minted-session' }) + }) + + it('mints a fresh session with no launcher slot and routes apply through the same composition', () => { const { ctx, calls } = recordingContext() tuiAgent.apply(ctx, { provider: 'mock', model: 'mock-model', - resumeSessionId: '', goals: false, workspaceContext: false, }) diff --git a/packages/examples/tui-demo/tsconfig.json b/packages/examples/tui-demo/tsconfig.json index d26d5b7da6..0ddf3b411c 100644 --- a/packages/examples/tui-demo/tsconfig.json +++ b/packages/examples/tui-demo/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../core/session" }, + { + "path": "../../util/paths" + }, { "path": "../../session-query/session-query" }, diff --git a/packages/guard/README.md b/packages/guard/README.md index b7375fd2bb..a164bdcddb 100644 --- a/packages/guard/README.md +++ b/packages/guard/README.md @@ -2,10 +2,11 @@ English | [中文](README.zh.md) -Behavioral guard plugins that watch the agent loop for unproductive patterns and nudge the model back on course. A single **product** package — there is no interface/implementation seam here, because a guard is a self-contained consumer of existing core seams (`tools/post-execute`, `agent/prompt-submit`, `agent/status`), not a swappable capability. +Behavioral guard plugins that watch the agent loop and correct it — some by nudging the model back on course, some by refusing an operation outright. All are **product** packages: there is no interface/implementation seam here, because a guard is a self-contained consumer of existing core seams (`tools/pre-execute`, `tools/post-execute`, `agent/prompt-submit`, `agent/status`), not a swappable capability. | Package | Role | ctx key | |---|---|---| | `repeat-tool-guard/` | Advisory reminders when an agent loops on identical tool calls | (listens on `ctx.tools`' waterfalls) | +| `source-guard/` | Denies file edits inside a dsh staging worktree until the required skill is loaded | (listens on `ctx.tools`' waterfalls) | -Reminders travel as `additionalContexts` on the `tools/post-execute` decision; the agent loop appends them as logged plugin-sourced `user/message` events after the step's tool results (see [the tools package](../core/tools)), so everything a guard says to the model is reconstructable from the session log. +An advisory guard's reminders travel as `additionalContexts` on the `tools/post-execute` decision; the agent loop appends them as logged plugin-sourced `user/message` events after the step's tool results (see [the tools package](../core/tools)), so everything such a guard says to the model is reconstructable from the session log. An enforcing guard instead decides on `tools/pre-execute`, where a `deny` becomes the call's error result and the operation never dispatches. diff --git a/packages/guard/source-guard/README.i18n.yaml b/packages/guard/source-guard/README.i18n.yaml new file mode 100644 index 0000000000..801d0979b6 --- /dev/null +++ b/packages/guard/source-guard/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/guard/source-guard/README.md +README.md: f083ef0af53c4d4f7c4a0875837ac3a3c851c54c +README.zh.md: 7c9efa7b8d3aec1a95b62416ef624a887fd48aa0 diff --git a/packages/guard/source-guard/README.md b/packages/guard/source-guard/README.md new file mode 100644 index 0000000000..f083ef0af5 --- /dev/null +++ b/packages/guard/source-guard/README.md @@ -0,0 +1,88 @@ +# @deepseek-ai/dsh-source-guard + +English | [中文](README.zh.md) + +An enforcement gate, not a model-facing tool: it never appears in the tool list and adds exactly one behavior — it denies a `write` or `edit` whose target sits inside the dsh checkout the running harness was launched from, on that checkout's own branch, until the calling session's durable log shows a successful load of the `dsh-customize` skill. That skill requires personal changes to be implemented in a task worktree and integrated under the staging lock; this plugin turns its central rule ("do not edit the personal staging checkout directly") from prompt guidance into a boundary the model cannot cross by forgetting. + +## Config + +```yaml +- id: source-guard + name: '@deepseek-ai/dsh-source-guard' + config: + requiredSkill: dsh-customize # default; the skill whose load lifts the denial + tools: [write, edit] # default; the gated tool names + protectedCheckout: /path/to/checkout # defaults to this module's own location +``` + +Every field fails loud at plugin load: an empty `tools` list, a blank `requiredSkill`, or a relative `protectedCheckout` throws, never a silent fall-back. + +`protectedCheckout` names a path inside the checkout to guard, and its worktree supplies BOTH protected identities: the repository and the exact branch. Its default is this module's own file, which resolves the checkout the running harness was launched from — the live deployment, whatever its branch is named. Nothing about the branch is configured or pattern-matched, so a maintainer whose staging branch follows no naming convention is protected identically. A harness running from an installed copy resolves a different repository, or none, and therefore guards nothing; the rule is meaningless outside a source checkout. + +The shipped TUI composition (`examples/tui-agent/cordis.yml`) loads this plugin with defaults. It is inert for anyone whose workspace is not the launcher's own checkout, so an ordinary project sees no change. + +## Which paths are protected + +Protection is decided by git identity read from files — `.git`, its `gitdir:` pointer, and `HEAD` — never by path prefix and never by running `git`. Prefix matching would be wrong here: the task worktrees the skill prescribes live *inside* the staging tree, at `/.worktrees/...`, and are exactly where edits belong. + +Resolution walks OUTWARD from the target and stops at the first enclosing worktree, so it reports the INNERMOST one. Denial needs that worktree to match the launcher's on BOTH identities: the same shared git directory and the same branch. A task worktree nested under the protected tree answers with its own task branch and passes; the launcher's own tree answers with the launcher's branch and is denied. Repository identity is compared on symlink-resolved paths, so two routes to one repository — a session cwd under `/var/...` and a configured path under `/private/var/...` on macOS — match rather than falling open. + +Requiring the exact branch, not a name pattern, keeps the gate on the live deployment only. A stale sibling checkout left by an earlier install shares the repository but runs no launcher, so the workflow rule does not apply to it and it stays editable. + +A `gitdir:` pointer may be absolute (what `git worktree add` writes) or relative, which git resolves against the worktree directory holding it; both resolve here. A relative `file_path` resolves against the calling session's workspace, exactly as the filesystem tools resolve it, so it is not an unguarded route to a protected file. + +The gate is deliberately narrow: + +- **`read` is never gated.** Inspecting the staging checkout violates nothing, so only mutating tools are candidates. +- **`bash` is not gated.** Reliably classifying mutating shell commands is out of scope, so a determined model can still change staging through a shell. +- **Calls without an agent are allowed.** A direct `ctx.tools.execute()` caller has no session to replay and no model to correct. +- **Unresolvable git state fails OPEN.** A path outside any worktree, a detached HEAD on either side, a different repository or branch, a malformed `.git` pointer, or unreadable metadata all leave the call to the rest of the chain. A gate that blocked every write whenever git identity was unavailable would cause more harm than the violation it prevents. +- **An unresolvable target is not judged.** An empty `file_path`, a non-string one, or a relative one in a session that names no workspace leaves the call to the tool's own validation. + +Worktree identity is cached per target directory for the plugin's lifetime, so repeated writes in one directory read git metadata once; a mid-session branch switch is therefore not observed. + +## How the denial lifts + +Satisfaction is replayed from the session's durable log: a `tool/call` naming the `skill` tool whose arguments parse to `{name: }`, paired by call id with a non-error `tool/result`. Because the log is the only state, satisfaction survives a session resume — a resumed session that already loaded the skill is not asked again. A failed load, a differently-named skill, and malformed argument JSON all leave the denial in place. + +Satisfaction is per session, so a subagent with its own session must load the skill itself. + +## Enforcement point + +The gate is a `tools/pre-execute` listener returning `{kind: 'deny', reason}`, so the call never dispatches and the file is never touched. It delegates via `next()` in every non-violating case. Denial — not an advisory reminder — is the point: an advisory nudge leaves the violation committed, and `ask` degrades to denial in a composition without approval support. + +## Testing + +Unit suites drive a real agent loop against a mock adapter over real git-metadata fixtures — a staging worktree, a task worktree nested inside it, a plain clone, a foreign repository on a staging-named branch, a detached HEAD, absolute and relative `gitdir:` pointers, a symlinked route to the same repository, and unreadable metadata — to per-file 100%. The assembled-run evidence is the Loader-composition smoke (`tests/loader-composition.e2e.ts`): it boots a real headless app over `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml`, seeds a staging worktree in a temporary cwd, and asserts the tool result is an error carrying the exact denial while the targeted file keeps its original bytes. + +## Model Experience + +### Denied filesystem call + +#### What the model sees + +A gated call into a protected worktree without the required skill loaded returns an error result carrying exactly the text below. No prompt section, tool schema, or successful-call text is added, and an allowed call is indistinguishable from one made without this plugin. + +##### Denial result + +```markdown +Error: Editing "" directly is not allowed: it is inside the dsh checkout this session is running from, on branch . Load the skill first and follow it — implement in a task worktree, then integrate under the staging lock. +``` + +#### Token effect + +Zero tokens while no denial occurs. A denial adds its small retained error result and avoids the success payload the call would have produced. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + +## Known Limitations and Deferred Work + +- **`bash` is ungated** — the guard is a boundary for the filesystem tools only; a shell command can still mutate a protected worktree. +- **Worktree identity is cached per directory for the plugin's lifetime** — switching a protected worktree's branch mid-session does not change decisions until the next load, on either the target or the launcher side. +- **Only the launcher's own checkout is protected** — a stale sibling checkout of the same repository stays editable, deliberately; run `dsh` from it to protect it. +- **Disarmed outside a source checkout** — a harness running from an installed copy protects nothing unless `protectedCheckout` names a real checkout explicitly. +- **Satisfaction is per session** — a subagent's session must load the skill itself; a parent's load does not carry over. +- **Fail-open on unresolvable git state** — a broken or unreadable `.git` means no protection, chosen deliberately over blocking every edit. +- **One skill lifts the whole gate for the session** — loading it does not verify the workflow was actually followed, only that the instructions were read. diff --git a/packages/guard/source-guard/README.zh.md b/packages/guard/source-guard/README.zh.md new file mode 100644 index 0000000000..7c9efa7b8d --- /dev/null +++ b/packages/guard/source-guard/README.zh.md @@ -0,0 +1,88 @@ +# @deepseek-ai/dsh-source-guard + +[English](README.md) | 中文 + +这是一道强制执行门禁,而非面向模型的工具:它不会出现在工具列表中,只增加一种行为。若 `write` 或 `edit` 的目标位于运行中 harness 启动来源的 dsh 检出目录内,并处于该检出目录自身的分支上,它会拒绝调用,直到调用方会话的持久日志表明已成功加载 `dsh-customize` skill(技能)。该 skill 要求在任务 worktree 中实现个人变更,并在 staging 锁保护下完成集成;本插件把其核心规则(「不要直接编辑个人 staging 检出目录」)从提示词指导变成一道模型无法因遗忘而越过的边界。 + +## 配置 + +```yaml +- id: source-guard + name: '@deepseek-ai/dsh-source-guard' + config: + requiredSkill: dsh-customize # default; the skill whose load lifts the denial + tools: [write, edit] # default; the gated tool names + protectedCheckout: /path/to/checkout # defaults to this module's own location +``` + +插件加载时,每个字段都会对错误配置快速失败:`tools` 为空列表、`requiredSkill` 为空白字符串,或 `protectedCheckout` 使用相对路径时,都会抛出错误,绝不静默回退。 + +`protectedCheckout` 指定位于待保护检出目录内的一条路径;其 worktree 会提供两项受保护身份:仓库和确切分支。其默认值是本模块自己的文件,由此解析出运行中 harness 启动来源的检出目录——当前运行的部署,无论其分支采用什么名称。分支既无需配置,也不会通过模式匹配,因此 staging 分支不遵循任何命名约定的维护者同样会受到保护。若 harness 从已安装副本运行,则会解析到另一个仓库,或根本解析不到仓库,因此不会保护任何内容;这条规则在源码检出目录之外没有意义。 + +已交付的 TUI 组合(`examples/tui-agent/cordis.yml`)会以默认配置加载本插件。若用户的工作区并非启动器自身所在的检出目录,本插件不会生效,因此普通项目不会发生任何变化。 + +## 受保护的路径 + +保护范围根据从文件读取的 Git 身份确定,即 `.git`、其中的 `gitdir:` 指针和 `HEAD`;既不按路径前缀判断,也不运行 `git`。此处若匹配路径前缀就会出错:skill 要求使用的任务 worktree 位于 staging 树*内部*的 `/.worktrees/...`,而这正是应该进行编辑的位置。 + +解析过程从目标路径开始向外逐层查找,遇到第一个所属 worktree 就停止,因此返回最内层的 worktree。只有该 worktree 在两项身份上都与启动器的 worktree 匹配,才会拒绝:共用同一个共享 Git 目录,且分支相同。嵌套在受保护树下的任务 worktree 会返回自己的任务分支并获准;启动器自身所在的树会返回启动器的分支并被拒绝。仓库身份会按解析符号链接后的路径进行比较,因此指向同一仓库的两条路径——macOS 上位于 `/var/...` 下的会话 cwd 和位于 `/private/var/...` 下的配置路径——会相互匹配,而不会触发故障放行(fail-open)。 + +要求匹配确切分支而非名称模式,可确保门禁仅作用于当前运行的部署。先前安装留下的陈旧同级检出目录虽然共享仓库,却没有运行启动器,因此该工作流规则不适用于它,它仍可编辑。 + +`gitdir:` 指针既可以是绝对路径(`git worktree add` 写入的形式),也可以是相对路径;Git 会以包含该指针的 worktree 目录为基准解析相对路径,本插件对两者都能解析。相对 `file_path` 会像文件系统工具一样,相对于调用会话的工作区解析,因此不会成为绕过门禁访问受保护文件的路径。 + +门禁刻意保持较窄的范围: + +- **`read` 从不受门禁限制。** 检查 staging 检出不构成违规,因此只有修改类工具是候选项。 +- **`bash` 不受门禁限制。** 可靠识别会修改内容的 shell 命令不在范围内,因此执意修改的模型仍可通过 shell 修改 staging。 +- **没有 agent(智能体)的调用会被放行。** 直接调用 `ctx.tools.execute()` 的调用方没有可供回放的会话,也没有需要纠正的模型。 +- **无法解析 Git 状态时故障放行。** 不属于任何 worktree 的路径、任一侧的 HEAD 分离状态、其他仓库或分支、格式错误的 `.git` 指针或不可读的元数据,都会把调用交给链中后续环节处理。若每逢 Git 身份不可用就阻止所有写入,这道门禁造成的危害将大于它所防止的违规。 +- **无法解析的目标不会被判断。** `file_path` 为空、不是字符串,或它是相对路径而会话未指定工作区时,调用会交给工具自身校验。 + +插件会在其整个生命周期内按目标目录缓存 worktree 身份,因此同一目录中的重复写入只读取一次 Git 元数据;由此,系统不会观察到会话中途的分支切换。 + +## 如何解除拒绝 + +是否满足解锁条件由会话的持久日志回放得出:日志中存在一条 `tool/call`,它调用名为 `skill` 的工具,参数可解析为 `{name: }`,并且有一条调用 id 相同的非错误 `tool/result` 与之配对。由于日志是唯一状态源,恢复会话时仍能保留这一结果:若恢复的会话已经加载该 skill,系统不会再次要求加载。加载失败、skill 名称不同或参数 JSON 格式错误,都会让拒绝继续生效。 + +解锁条件按会话独立满足,因此拥有独立会话的 subagent 必须自行加载该 skill。 + +## 强制执行点 + +门禁是一个 `tools/pre-execute` 监听器,返回 `{kind: 'deny', reason}`,因此调用绝不会分派执行,文件也绝不会被修改。在所有不违规的情况下,它都会通过 `next()` 委派。这里刻意采用拒绝而非建议性提醒:建议性提醒仍会让违规落地,而在不支持批准的组合中,`ask` 会退化为拒绝。 + +## 测试 + +单元测试套件基于真实 Git 元数据 fixture(测试前置数据),使用 mock 适配器驱动真实 agent loop(智能体循环):覆盖 staging worktree、嵌套其中的任务 worktree、普通克隆、位于 staging 命名分支上的其他仓库、HEAD 分离状态、绝对和相对 `gitdir:` 指针、指向同一仓库的符号链接路径以及不可读元数据,达到逐文件 100% 覆盖率。组装运行层面的证据来自 Loader 组合冒烟测试(`tests/loader-composition.e2e.ts`):它通过 `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml` 启动一个真实的 headless 应用,在临时 cwd 中植入 staging worktree,并断言工具结果是携带精确拒绝文本的错误,同时目标文件保持原始字节不变。 + +## 模型体验 + +### 被拒绝的文件系统调用 + +#### 模型看到的内容 + +如果未加载必需 skill 就对受保护 worktree 发起受门禁限制的调用,系统会返回错误结果,其中的文本与下文完全一致。系统不会添加提示词段、工具 schema 或成功调用文本;允许的调用与未启用此插件时的调用完全无法区分。 + +##### 拒绝结果 + +```markdown +Error: Editing "" directly is not allowed: it is inside the dsh checkout this session is running from, on branch . Load the skill first and follow it — implement in a task worktree, then integrate under the staging lock. +``` + +#### Token 影响 + +未发生拒绝时为零 token。一次拒绝会添加一条会保留在历史中的短小错误结果,同时避免生成该调用原本会产生的成功载荷。 + +#### KV Cache 影响 + +仅追加;新出现的内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与暂缓工作 + +- **`bash` 不受门禁限制**:此插件只为文件系统工具提供边界;shell 命令仍可修改受保护的 worktree。 +- **插件生命周期内按目录缓存 worktree 身份**:在会话中途切换受保护 worktree 的分支,不会改变判断结果,直至下次加载插件;目标侧和启动器侧都是如此。 +- **仅保护启动器自身的检出目录**:同一仓库中的陈旧同级检出目录会被刻意保留为可编辑状态;若要保护它,请从中运行 `dsh`。 +- **源码检出之外不启用**:从已安装副本运行的 harness 不保护任何内容,除非 `protectedCheckout` 明确指定真实检出目录。 +- **解锁条件按会话独立满足**:subagent 的会话必须自行加载该 skill;父会话的加载状态不会继承。 +- **无法解析 Git 状态时故障放行**:损坏或不可读的 `.git` 会使保护失效;这是刻意选择的结果,因为另一方案是阻止所有编辑。 +- **仅加载一个 skill 即可为会话解除整道门禁**:加载该 skill 并不能验证是否实际遵循工作流,只能证明已阅读这些指令。 diff --git a/packages/guard/source-guard/package.json b/packages/guard/source-guard/package.json new file mode 100644 index 0000000000..4e893e0a69 --- /dev/null +++ b/packages/guard/source-guard/package.json @@ -0,0 +1,56 @@ +{ + "name": "@deepseek-ai/dsh-source-guard", + "description": "Source-guard plugin: denies direct file edits inside a dsh staging worktree until the required customization skill is loaded", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "schemastery": "^3.18.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-fs": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/guard/source-guard/src/index.ts b/packages/guard/source-guard/src/index.ts new file mode 100644 index 0000000000..4e242efbd0 --- /dev/null +++ b/packages/guard/source-guard/src/index.ts @@ -0,0 +1,319 @@ +/** + * Denies model-driven file mutation inside a dsh staging worktree until the + * calling session has loaded the required customization skill. Config, git + * resolution, and satisfaction semantics live in the package README; rationale + * lives in the source-guard Agent Note. + * @module @deepseek-ai/dsh-source-guard + */ + +import { dirname, isAbsolute, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Context } from 'cordis' +import z from 'schemastery' +import { canonicalPath } from '@deepseek-ai/dsh-sandbox' +import type {} from '@deepseek-ai/dsh-fs' +import type { CallId } from '@deepseek-ai/dsh-llm' +import type { Session } from '@deepseek-ai/dsh-session' +import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools' + +export const name = 'source-guard' + +/** The `ctx.fs` provider supplies the git-metadata reads this guard resolves paths with. */ +export const inject = ['fs'] + +/** + * Plugin config, validated by the same-named schemastery schema plus the + * load-time checks in `apply` (misconfiguration fails loud: an empty `tools` + * list, a blank `requiredSkill`, or a relative `protectedCheckout` throws at + * plugin load, never a silent fall-back). + */ +export interface Config { + /** Skill whose loaded presence in the session lifts the denial (default `dsh-customize`). */ + requiredSkill?: string + /** Tool names to gate (default `['write', 'edit']`). */ + tools?: string[] + /** + * Absolute path inside the checkout this guard protects. Its worktree + * supplies BOTH protected identities: the repository (targets in any other + * repository are ignored) and the exact branch (only that branch's worktree + * is protected). Defaults to this module's own location, which resolves the + * checkout the running harness was launched from — the live deployment, + * whatever its branch is named. Set it explicitly to guard a different + * checkout, or when the harness runs from an installed copy whose own + * location is not a checkout at all. + */ + protectedCheckout?: string +} + +export const Config: z = z.object({ + requiredSkill: z.string().default('dsh-customize'), + tools: z.array(z.string()).default(['write', 'edit']), + protectedCheckout: z.string().default(fileURLToPath(import.meta.url)), +}) + +/** + * The tool whose successful call satisfies the guard. Fixed, not configurable: + * this is the harness's own skill-loading tool name, so a deployment that + * renamed it has no skill to load and nothing for this guard to observe. + */ +const SKILL_TOOL = 'skill' + +/** + * The argument key every gated tool names its target with. `write` and `edit` + * share it (`dsh-tool-fs`), and gating a tool that does not is a + * misconfiguration the guard reports rather than silently allowing. + */ +const PATH_ARGUMENT = 'file_path' + +/** + * The absolute `file_path` a gated call targets, or `undefined` when the + * arguments carry no usable one. Arguments arrive as the loop's parsed model + * JSON, so this is a model-input boundary: any shape is possible. + * + * A relative path resolves against the calling session's workspace, exactly as + * the filesystem tools resolve it (`dsh-tool-fs`'s `sessionCwd`). Judging only + * absolute paths would leave `write` with a relative `file_path` as an + * unguarded path to the same file. + */ +function targetPath(argumentsValue: unknown, sessionCwd: string | undefined): string | undefined { + if (typeof argumentsValue !== 'object' || argumentsValue === null) return undefined + const value = (argumentsValue as Record)[PATH_ARGUMENT] + if (typeof value !== 'string' || value.length === 0) return undefined + if (isAbsolute(value)) return resolve(value) + // Without a session cwd the tools fall back to a provider-owned default this + // guard cannot observe, so the target is genuinely unresolvable here. + return sessionCwd === undefined ? undefined : resolve(sessionCwd, value) +} + +/** One resolved worktree's identity: the branch its HEAD names, and the repository it belongs to. */ +interface Worktree { + /** Branch name from `HEAD`, or `undefined` for a detached HEAD. */ + branch: string | undefined + /** + * Symlink-resolved absolute path of the shared git directory, identifying the + * repository across worktrees. Canonical because two paths reaching one + * repository by different symlink routes must compare equal — on macOS a + * session cwd under `/var/...` and a configured path under `/private/var/...` + * name the same directory, and a lexical comparison would fail open. + */ + commonDir: string +} + +/** + * What one git-metadata path holds: a file's text, the fact that it is a + * directory, or nothing resolvable. Every caller treats the unresolvable case + * as "not a worktree" and lets the call proceed, so distinguishing absence + * from a permission error would change no decision. + */ +type GitEntry = + | { kind: 'file'; text: string } + | { kind: 'directory' } + | { kind: 'absent' } + +/** Probe one git-metadata path, reading its text when it is a regular file. */ +async function readGitEntry(ctx: Context, path: string): Promise { + try { + const target = await ctx.fs.resolve(path) + const info = await ctx.fs.stat(target) + if (info?.type === 'directory') return { kind: 'directory' } + if (info?.type !== 'file') return { kind: 'absent' } + return { kind: 'file', text: await ctx.fs.readText(target) } + } catch { + // Any resolve/stat/read failure (absent, denied, unreadable encoding) + // yields no git identity. Nothing else can reach here: the guard performs + // no other IO. + return { kind: 'absent' } + } +} + +/** + * Branch name from a `HEAD` file's contents. A symbolic ref names a branch; a + * detached HEAD holds a raw object id and has no branch, which no staging + * pattern can match. + */ +function branchFromHead(head: string): string | undefined { + const trimmed = head.trim() + const ref = 'ref: refs/heads/' + return trimmed.startsWith(ref) ? trimmed.slice(ref.length) : undefined +} + +/** + * Resolve the git directory a worktree root's `.git` entry designates, plus + * the shared common directory. A plain clone's `.git` is a directory that is + * its own common dir; a linked worktree's `.git` is a file pointing into the + * main repository's `worktrees/`, whose common dir is two levels up. + * A `gitdir:` pointer may be relative, which git resolves against the worktree + * directory holding it. + */ +async function resolveGitDir(ctx: Context, root: string): Promise<{ gitDir: string; commonDir: string } | undefined> { + const dotGit = resolve(root, '.git') + const entry = await readGitEntry(ctx, dotGit) + // A plain clone keeps a `.git` DIRECTORY, which is both the git dir and the + // common dir; a linked worktree keeps a `.git` FILE pointing elsewhere. + if (entry.kind === 'directory') return { gitDir: dotGit, commonDir: canonicalPath(dotGit) } + if (entry.kind === 'absent') return undefined + const prefix = 'gitdir:' + const trimmed = entry.text.trim() + if (!trimmed.startsWith(prefix)) return undefined + const pointer = trimmed.slice(prefix.length).trim() + if (pointer.length === 0) return undefined + const gitDir = resolve(root, pointer) + // `/worktrees/` — the shared repository is two levels up. + return { gitDir, commonDir: canonicalPath(dirname(dirname(gitDir))) } +} + +/** + * Walk from a path toward the filesystem root and resolve the first enclosing + * worktree, or `undefined` when the path is inside none. + */ +async function findWorktree(ctx: Context, from: string): Promise { + let current = from + for (;;) { + const dirs = await resolveGitDir(ctx, current) + if (dirs !== undefined) { + const head = await readGitEntry(ctx, resolve(dirs.gitDir, 'HEAD')) + return { + branch: head.kind === 'file' ? branchFromHead(head.text) : undefined, + commonDir: dirs.commonDir, + } + } + const parent = dirname(current) + if (parent === current) return undefined + current = parent + } +} + +/** + * The skill name a `skill` call's raw argument JSON requested, or `undefined` + * when the JSON is malformed or carries no string `name`. The log stores the + * model's unparsed argument string, so this is a model-JSON boundary. + */ +function skillNameOf(rawArguments: string): string | undefined { + let parsed: unknown + try { + parsed = JSON.parse(rawArguments) + } catch { + // The model produced argument text that is not JSON; the call cannot have + // named a skill. Nothing else in this try can throw. + return undefined + } + if (typeof parsed !== 'object' || parsed === null) return undefined + const value = (parsed as Record).name + return typeof value === 'string' ? value : undefined +} + +/** + * Whether the session's durable log records a successful load of + * `requiredSkill`. Replayed from `tool/call` + `tool/result` pairs, so + * satisfaction survives a session resume: the log is the only state. + */ +function skillLoaded(session: Session, requiredSkill: string): boolean { + const requested = new Map() + for (const event of session.events) { + if (event.type === 'tool/call') { + if (event.data.name === SKILL_TOOL) requested.set(event.data.callId, event.data.arguments) + continue + } + const block = event.type === 'tool/result' ? event.data.message.content[0] : undefined + if (block === undefined || block.isError === true) continue + const rawArguments = requested.get(block.toolCallId) + if (rawArguments !== undefined && skillNameOf(rawArguments) === requiredSkill) return true + } + return false +} + +/** The denial text a blocked call reports to the model. */ +function denialReason(path: string, branch: string, requiredSkill: string): string { + return `Editing "${path}" directly is not allowed: it is inside the dsh checkout this session is running from, on branch ${branch}. ` + + `Load the ${requiredSkill} skill first and follow it — implement in a task worktree, then integrate under the staging lock.` +} + +/** + * Install the guard's listener. + * @param ctx - plugin context; the listener is scoped to it and disposed with it. + * @param config - validated {@link Config}; re-checked fail-loud here. + */ +export function apply(ctx: Context, config: Config): void { + // schemastery's .default() guarantees the fields are set after validation. + const requiredSkill = config.requiredSkill as string + const tools = config.tools as string[] + if (tools.length === 0) { + throw new Error('source-guard: `tools` must not be empty') + } + if (requiredSkill.trim().length === 0) { + throw new Error('source-guard: `requiredSkill` must not be blank') + } + const gated = new Set(tools) + + const protectedCheckout = config.protectedCheckout as string + if (!isAbsolute(protectedCheckout)) { + throw new Error(`source-guard: \`protectedCheckout\` must be an absolute path, got "${protectedCheckout}"`) + } + // Resolved once per plugin lifetime: the worktree this guard arms for, which + // supplies both the protected repository and the protected branch. A harness + // running from an installed copy resolves a different repository (or none) + // and therefore guards nothing, which is correct — the rule is meaningless + // outside a source checkout. + let protectedRepository: Promise | undefined + + /** The repository containing {@link Config.protectedCheckout}. */ + function repository(): Promise { + protectedRepository ??= findWorktree(ctx, dirname(protectedCheckout)) + return protectedRepository + } + + // Worktree identity per directory, cached for the plugin's lifetime: a + // directory's repository and branch are stable in practice, and re-reading + // git metadata on every write would repeat identical IO. A mid-session + // branch switch is therefore not observed (see the README). + const worktrees = new Map>() + + /** Resolve (and memoize) the worktree enclosing a target path's directory. */ + function worktreeOf(path: string): Promise { + const directory = dirname(path) + let pending = worktrees.get(directory) + if (pending === undefined) { + pending = findWorktree(ctx, directory) + worktrees.set(directory, pending) + } + return pending + } + + /** + * The target path and the staging branch protecting it, or `undefined` when + * the call may proceed. Fails open on every unresolvable case: a path outside + * any worktree, a detached HEAD, a different repository, or unreadable git + * metadata leaves the call to the rest of the chain, because a guard that + * blocked writes whenever git identity was unavailable would be worse than + * the violation it prevents. + */ + async function protectedTarget(exec: ToolExecution, session: Session): Promise<{ path: string; branch: string } | undefined> { + if (!gated.has(exec.name)) return undefined + const path = targetPath(exec.arguments, session.header.cwd) + if (path === undefined) return undefined + const launcher = await repository() + // A detached launcher checkout names no branch to protect, so nothing is. + if (launcher?.branch === undefined) return undefined + // Resolution walks OUTWARD from the target, so it reports the INNERMOST + // enclosing worktree: a task worktree nested under the protected tree + // answers with its own task branch, which is not the launcher's. That is + // what keeps the prescribed workflow unblocked. + const worktree = await worktreeOf(path) + if (worktree === undefined || worktree.commonDir !== launcher.commonDir) return undefined + // Only the branch the launcher itself runs from is protected: a stale + // sibling checkout of the same repository is not the live deployment. + if (worktree.branch !== launcher.branch) return undefined + return { path, branch: launcher.branch } + } + + ctx.on('tools/pre-execute', async (exec, next): Promise => { + // A direct `ctx.tools.execute()` caller has no session to replay and no + // model to correct; only agent-loop calls are gated. + if (exec.agent === undefined) return next() + const { session } = exec.agent + const target = await protectedTarget(exec, session) + if (target === undefined) return next() + if (skillLoaded(session, requiredSkill)) return next() + return { kind: 'deny', reason: denialReason(target.path, target.branch, requiredSkill) } + }) +} diff --git a/packages/guard/source-guard/src/invariant.ts b/packages/guard/source-guard/src/invariant.ts new file mode 100644 index 0000000000..f5b68cf24d --- /dev/null +++ b/packages/guard/source-guard/src/invariant.ts @@ -0,0 +1,85 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-source-guard`. + * @module @deepseek-ai/dsh-source-guard/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' + +const PACKAGE_NAME = '@deepseek-ai/dsh-source-guard' + +/** Cordis companion plugin name. */ +export const name = 'source-guard-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * The durable shape of this guard's refusal. The denial is the package's only + * model-visible output, and it is actionable only when it names all three of + * the offending path, the branch that protects it, and the skill that lifts + * the denial — a refusal missing any of them tells the model to stop without + * telling it how to proceed. + */ +const DENIAL = new RegExp( + '^Error: Editing "(?.+)" directly is not allowed: ' + + 'it is inside the dsh checkout this session is running from, on branch (?\\S+)\\. ' + + 'Load the (?\\S+) skill first and follow it ' + + '— implement in a task worktree, then integrate under the staging lock\\.$', +) + +/** The denial prefix identifying a result this package produced, before its full shape is validated. */ +const DENIAL_PREFIX = 'Error: Editing "' + +/** Validate one guard-produced denial result's model-facing text. */ +function validateDenial(text: string, fail: InvariantFailure): void { + const match = DENIAL.exec(text) + if (match === null) { + fail('source-guard denial must name the path, the protecting branch, and the skill that lifts it') + } + // The pattern's `\S+` groups already establish a non-empty branch and skill; + // only path absoluteness remains to check. + const { path } = match.groups as { path: string } + if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) { + fail(`source-guard denial must name an absolute path, got ${JSON.stringify(path)}`) + } +} + +/** Validate every guard denial carried by one session's durable log. */ +function validateSession(session: Session, fail: InvariantFailure): void { + for (const event of session.events) { + if (event.type !== 'tool/result') continue + validateEvent(event, fail) + } +} + +/** Validate one durable tool result, when it carries this package's denial. */ +function validateEvent(event: SessionEvent<'tool/result'>, fail: InvariantFailure): void { + const result = event.data.message.content[0] + if (result.isError !== true) return + for (const block of result.content) { + if (block.type !== 'text' || !block.text.startsWith(DENIAL_PREFIX)) continue + validateDenial(block.text, fail) + } +} + +/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */ +/** Install validation for loaded and newly appended denial results. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + for (const session of ctx.sessions.list()) validateSession(session, fail) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const [, event] = args as [Session, SessionEvent] + if (event.type !== 'tool/result') return + validateEvent(event, fail) + }, { global: true }) +}, { inject: ['sessions'] }) +/* jscpd:ignore-end */ + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/guard/source-guard/tests/invariant.spec.ts b/packages/guard/source-guard/tests/invariant.spec.ts new file mode 100644 index 0000000000..cd51c1a9a1 --- /dev/null +++ b/packages/guard/source-guard/tests/invariant.spec.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { CallId, createToolResultMessage, createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SourceGuardInvariant from '@deepseek-ai/dsh-source-guard/invariant' + +/** + * The companion validates the durable shape of this package's only + * model-visible output: its refusal must name the offending path, the branch + * that protects it, and the skill that lifts it, so the model can act on the + * denial instead of merely stopping. + */ + +const PATH = '/repo/staging/file.ts' + +/** A well-formed denial for `path`, as the guard materializes it into a tool result. */ +function denial(path = PATH, branch = 'dsh-staging/20260101T000000Z', skill = 'dsh-customize'): string { + return `Error: Editing "${path}" directly is not allowed: it is inside the dsh checkout this session is running from, ` + + `on branch ${branch}. Load the ${skill} skill first and follow it ` + + '— implement in a task worktree, then integrate under the staging lock.' +} + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService, { enabled: true }) + await ctx.plugin(SourceGuardInvariant) + return ctx +} + +/** One durable tool result carrying `content`, error-flagged unless told otherwise. */ +function result(content: unknown[], isError = true): SessionEvent { + return { + type: 'tool/result', + seq: 0, + time: 1, + surfaceOp: 'append', + sourceEventSeqs: [0], + data: { + turn: 1, + step: 1, + message: createToolResultMessage({ + callId: CallId('c0'), + content: content as ContentBlock[], + isError, + }), + }, + } +} + +describe('source-guard invariants', () => { + it('accepts a denial naming the path, branch, and skill', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('accept')) + expect(() => { ctx.emit('session/event', session, result([{ type: 'text', text: denial() }])) }).not.toThrow() + }) + + it('accepts a Windows-style absolute path', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('accept-windows')) + const event = result([{ type: 'text', text: denial(String.raw`C:\repo\staging\file.ts`) }]) + expect(() => { ctx.emit('session/event', session, event) }).not.toThrow() + }) + + it.each([ + ['a successful result that merely quotes the prefix', false], + ])('ignores %s', async (_label, isError) => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('ignore-success')) + const event = result([{ type: 'text', text: 'Error: Editing "x" was fine' }], isError) + expect(() => { ctx.emit('session/event', session, event) }).not.toThrow() + }) + + it.each([ + ['a non-text block', [{ type: 'image', data: 'x', mimeType: 'image/png' }]], + ['text that is not this package\'s denial', [{ type: 'text', text: 'Error: something else' }]], + ])('ignores %s', async (_label, content) => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('ignore-other')) + expect(() => { ctx.emit('session/event', session, result(content)) }).not.toThrow() + }) + + it('ignores an event that is not a tool result', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('ignore-kind')) + const event: SessionEvent = { + type: 'user/message', + seq: 0, + time: 1, + surfaceOp: 'append', + data: createUserMessage({ content: [{ type: 'text', text: denial() }], source: { kind: 'user' } }), + } + expect(() => { ctx.emit('session/event', session, event) }).not.toThrow() + }) + + it.each([ + [ + 'omits the skill that lifts it', + `Error: Editing "${PATH}" directly is not allowed: it is inside the dsh checkout this session is running from, on branch main.`, + ], + [ + 'names a relative path', + denial('relative/file.ts'), + ], + ])('rejects a denial that %s', async (_label, text) => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('reject')) + expect(() => { ctx.emit('session/event', session, result([{ type: 'text', text }])) }).toThrow(/source-guard denial/) + }) + + it('rejects an invalid denial already present on late registration', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('late')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + const call = session.append('tool/call', { + turn: 1, step: 1, callId: CallId('c0'), name: 'write', arguments: '{}', + }) + session.append('tool/result', { + turn: 1, + step: 1, + message: createToolResultMessage({ + callId: CallId('c0'), + content: [{ type: 'text', text: denial('relative/file.ts') }], + isError: true, + }), + }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] }) + + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(SourceGuardInvariant).then(() => undefined)).rejects.toThrow(/source-guard denial/) + }) +}) diff --git a/packages/guard/source-guard/tests/loader-composition.e2e.ts b/packages/guard/source-guard/tests/loader-composition.e2e.ts new file mode 100644 index 0000000000..2595dc8d2a --- /dev/null +++ b/packages/guard/source-guard/tests/loader-composition.e2e.ts @@ -0,0 +1,93 @@ +import { mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { type SessionEvent } from '@deepseek-ai/dsh-session' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' + +// The Loader config lives under examples so both launch modes exercise the same +// deployable topology: a local fixture adapter plus bare workspace plugins. +const configPath = fileURLToPath(new URL( + '../../../../examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml', + import.meta.url, +)) +const binScript = fileURLToPath(new URL('../../../examples/cli-demo/src/bin.ts', import.meta.url)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +/** Every `.jsonl` session log under `dir`. */ +async function jsonlFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const paths = await Promise.all(entries.map(async (entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return jsonlFiles(path) + return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] + })) + return paths.flat() +} + +/** + * Write git metadata mirroring the installer layout — a master clone owning the + * shared git directory and one linked worktree on a staging branch — and return + * the worktree file the model will try to write. + */ +async function stagingFixture(cwd: string): Promise<{ checkout: string; target: string }> { + const gitDir = join(cwd, 'master', '.git') + const worktreeGitDir = join(gitDir, 'worktrees', 'staging') + await mkdir(worktreeGitDir, { recursive: true }) + await writeFile(join(gitDir, 'HEAD'), 'ref: refs/heads/master\n') + await writeFile(join(worktreeGitDir, 'HEAD'), 'ref: refs/heads/dsh-staging/20260101T000000Z\n') + const checkout = join(cwd, 'staging') + await mkdir(checkout, { recursive: true }) + await writeFile(join(checkout, '.git'), `gitdir: ${worktreeGitDir}\n`) + const target = join(checkout, 'guarded.ts') + await writeFile(target, 'original\n') + return { checkout, target } +} + +describe('source-guard through a real headless cordis.yml', () => { + it('denies the model-requested write and leaves the staged file untouched', async () => { + let events: SessionEvent[] = [] + let contents = '' + let target = '' + const { stderr } = await runLoaderSmoke({ + label: 'source-guard headless smoke', + tempDirPrefix: 'source-guard-e2e-', + binScript, + configPath, + tsconfigPath: repoTsconfig, + binArgs: ['--config', configPath, 'edit the guarded file'], + // The isolated cwd is not known when these options are built, so the + // config and adapter resolve their fixture paths against the child's own + // cwd, which is that directory. + prepare: async (cwd) => { + // macOS puts the temp directory behind the /var -> /private/var + // symlink; the child resolves its cwd, so compare against the same + // real path rather than the symlinked one this process was handed. + target = (await stagingFixture(await realpath(cwd))).target + }, + inspect: async (cwd) => { + const logs = await jsonlFiles(join(cwd, '.sessions')) + expect(logs).toHaveLength(1) + const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') + events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) + contents = await readFile(target, 'utf8') + }, + }) + expect(stderr).not.toContain('UNHANDLED') + + const results = events.filter( + (event): event is SessionEvent<'tool/result'> => event.type === 'tool/result') + expect(results).toHaveLength(1) + const result = results[0]?.data.message.content[0] + expect(result?.isError).toBe(true) + const text = result?.content.map(block => block.type === 'text' ? block.text : '').join('') + expect(text).toBe( + `Error: Editing "${target}" directly is not allowed: it is inside the dsh checkout this session is running from, ` + + 'on branch dsh-staging/20260101T000000Z. Load the dsh-customize skill first and follow it ' + + '— implement in a task worktree, then integrate under the staging lock.', + ) + // Enforcement, not advice: the guard denies before dispatch, so the file + // the model targeted still holds its original bytes. + expect(contents).toBe('original\n') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/packages/guard/source-guard/tests/source-guard.spec.ts b/packages/guard/source-guard/tests/source-guard.spec.ts new file mode 100644 index 0000000000..ccbc7d7c2b --- /dev/null +++ b/packages/guard/source-guard/tests/source-guard.spec.ts @@ -0,0 +1,581 @@ +import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import { CallId, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import type { Agent } from '@deepseek-ai/dsh-agent' +import * as SourceGuard from '@deepseek-ai/dsh-source-guard' +import type { Config } from '@deepseek-ai/dsh-source-guard' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' + +/** + * Behavior suite for the staging-source guard: worktree resolution over REAL + * git metadata fixtures (a staging worktree, a nested task worktree, a plain + * clone, an unrelated repository, a detached HEAD), skill satisfaction replayed + * from the durable session log, and fail-loud config validation — all driven + * through a real agent loop against a scripted mock adapter (no network). + */ + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +/** + * Build a git-metadata fixture tree that mirrors the real installer layout: a + * `master` clone holding the shared git directory, linked worktrees registered + * under `master/.git/worktrees/`, and one file per worktree to target. + */ +async function fixture(): Promise<{ + /** Absolute path of the fixture container. */ + root: string + /** A file inside the staging worktree — the protected target. */ + stagingFile: string + /** A file inside a task worktree NESTED under the staging tree. */ + taskFile: string + /** A file inside a SIBLING staging worktree of the same repository, on another branch. */ + siblingFile: string + /** A file inside the plain master clone. */ + masterFile: string + /** A file inside a worktree whose HEAD is detached. */ + detachedFile: string + /** A file inside an unrelated repository sharing no git directory. */ + outsideFile: string + /** A file under no repository at all. */ + looseFile: string +}> { + const root = await mkdtemp(join(tmpdir(), 'source-guard-')) + roots.push(root) + const master = join(root, 'master') + const gitDir = join(master, '.git') + await mkdir(join(gitDir, 'worktrees'), { recursive: true }) + await writeFile(join(gitDir, 'HEAD'), 'ref: refs/heads/master\n') + await writeFile(join(master, 'file.ts'), 'master\n') + + /** Register one linked worktree at `path` whose HEAD file holds `head`. */ + async function linked(path: string, name: string, head: string): Promise { + const worktreeGitDir = join(gitDir, 'worktrees', name) + await mkdir(worktreeGitDir, { recursive: true }) + await writeFile(join(worktreeGitDir, 'HEAD'), head) + await mkdir(path, { recursive: true }) + await writeFile(join(path, '.git'), `gitdir: ${worktreeGitDir}\n`) + const file = join(path, 'file.ts') + await writeFile(file, 'content\n') + return file + } + + const staging = join(root, 'staging-20260728T022827Z') + const stagingFile = await linked(staging, 'staging-20260728T022827Z', 'ref: refs/heads/dsh-staging/20260728T022827Z\n') + // The prescribed workflow's task worktree lives INSIDE the staging tree. + const taskFile = await linked(join(staging, '.worktrees', 'task', 'x'), 'task-x', 'ref: refs/heads/task/x\n') + // A stale staging worktree from an earlier install: same repository, different branch. + const siblingFile = await linked( + join(root, 'staging-20260727T045831Z'), + 'staging-20260727T045831Z', + 'ref: refs/heads/dsh-staging/20260727T045831Z\n', + ) + const detachedFile = await linked(join(root, 'detached'), 'detached', '0123456789abcdef0123456789abcdef01234567\n') + + const outside = join(root, 'outside') + await mkdir(join(outside, '.git'), { recursive: true }) + await writeFile(join(outside, '.git', 'HEAD'), 'ref: refs/heads/dsh-staging/20260728T022827Z\n') + const outsideFile = join(outside, 'file.ts') + await writeFile(outsideFile, 'outside\n') + + const loose = join(root, 'loose') + await mkdir(loose, { recursive: true }) + const looseFile = join(loose, 'file.ts') + await writeFile(looseFile, 'loose\n') + + return { + root, stagingFile, taskFile, siblingFile, masterFile: join(master, 'file.ts'), detachedFile, outsideFile, looseFile, + } +} + +/** + * Boot the core spine, a real local filesystem, and the guard, pointing + * `protectedCheckout` at a fixture path so the guard arms for the fixture + * repository instead of the checkout these tests actually run in. + */ +async function harness(protectedCheckout: string, config: Partial = {}): Promise { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(LocalFileSystem, {}) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SourceGuard, { ...config, protectedCheckout }) + for (const name of ['write', 'edit', 'read', 'skill']) { + ctx.tools.register(defineContentToolFixture({ + name, + description: name, + parameters: { file_path: { type: 'string' }, name: { type: 'string' } }, + async execute() { return [{ type: 'text', text: 'ok' }] }, + })) + } + return ctx +} + +function waitForIdle(ctx: Context, agent: Agent): Promise { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { dispose(); resolve() } + }) + }) +} + +/** Every tool result in the agent's log as `{ isError, text }`, in log order. */ +function results(agent: Agent): { isError: boolean; text: string }[] { + return [...agent.session.events] + .filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result') + .map(event => event.data.message.content[0]) + .map(result => ({ + isError: result.isError === true, + text: result.content.map(block => block.type === 'text' ? block.text : '').join(''), + })) +} + +/** + * Durable events recording completed `skill` calls, as a RESUMED session's seed: + * the guard's satisfaction check then has nothing but the log to read, with no + * in-memory state from an original run to fall back on. + */ +function priorSkillCalls(calls: { arguments: string; isError?: boolean }[]): SessionEvent[] { + const events: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, + ] + for (const [index, call] of calls.entries()) { + const callId = CallId(`prior${index}`) + const seq = events.length + events.push({ + type: 'tool/call', + seq, + time: seq + 1, + data: { turn: 1, step: 1, callId, name: 'skill', arguments: call.arguments }, + }) + events.push({ + type: 'tool/result', + seq: seq + 1, + time: seq + 2, + surfaceOp: 'append', + sourceEventSeqs: [seq], + data: { + turn: 1, + step: 1, + message: createToolResultMessage({ + callId, + content: [{ type: 'text', text: 'loaded' }], + isError: call.isError ?? false, + }), + }, + }) + } + const tail = events.length + events.push({ type: 'step/end', seq: tail, time: tail + 1, data: { turn: 1, step: 1 } }) + events.push({ type: 'turn/end', seq: tail + 1, time: tail + 2, data: { turn: 1, reason: { kind: 'completed' } } }) + return events +} + +/** Resume a session from durable seed events and let the model attempt one write at `path`. */ +async function resume(ctx: Context, id: string, seed: SessionEvent[], path: string): Promise { + const adapter = new MockAdapter([ + toolCallResponse(CallId('c0'), 'write', { file_path: path }), + textResponse('done'), + ]) + ctx.llm.registerAdapter(['mock'], adapter) + const { agent } = await ctx.agentLoop.createAgent(ctx, { + sessionId: SessionId(id), + seed, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) + await waitForIdle(ctx, agent) + return agent +} + +/** Drive one turn whose scripted model output is the given tool calls, then a closing text. */ +async function run( + ctx: Context, + calls: { name: string; args: Record }[], + cwd?: string, +): Promise { + const adapter = new MockAdapter([ + ...calls.map((call, index) => toolCallResponse(CallId(`c${index}`), call.name, call.args)), + textResponse('done'), + ]) + ctx.llm.registerAdapter(['mock'], adapter) + const agent = ctx.agentLoop.create( + SessionId('s1'), + { provider: 'mock', model: 'mock' }, + cwd === undefined ? {} : { cwd }, + ) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) + await waitForIdle(ctx, agent) + return agent +} + +describe('staging protection', () => { + it('denies a write inside the staging worktree and names the path, branch, and skill', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }]) + const [result] = results(agent) + expect(result?.isError).toBe(true) + expect(result?.text).toBe( + `Error: Editing "${paths.stagingFile}" directly is not allowed: it is inside the dsh checkout this session is running from, ` + + 'on branch dsh-staging/20260728T022827Z. Load the dsh-customize skill first and follow it ' + + '— implement in a task worktree, then integrate under the staging lock.', + ) + }) + + it('denies an edit inside the staging worktree', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'edit', args: { file_path: paths.stagingFile } }]) + expect(results(agent)[0]?.isError).toBe(true) + }) + + it('allows a read inside the staging worktree, since inspection never violates the skill', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'read', args: { file_path: paths.stagingFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('allows a write inside a task worktree nested under the staging tree', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.taskFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('allows a write in the plain clone that owns the shared git directory', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.masterFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('allows a write on a staging-named branch in an unrelated repository', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.outsideFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('allows a write under a detached HEAD, which names no branch to match', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.detachedFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('allows a write when the git metadata exists but cannot be read', async () => { + const paths = await fixture() + // A `.git` pointer that stats as a file yet fails to read leaves the guard + // with no branch to judge; failing open beats blocking every edit. + const unreadable = join(paths.root, 'unreadable') + await mkdir(unreadable, { recursive: true }) + await writeFile(join(unreadable, '.git'), `gitdir: ${join(paths.root, 'master', '.git')}\n`) + await chmod(join(unreadable, '.git'), 0o000) + const file = join(unreadable, 'file.ts') + await writeFile(file, 'content\n') + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('allows a write under no repository at all', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.looseFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('arms for nothing when its own location is inside no repository', async () => { + const paths = await fixture() + const ctx = await harness(paths.looseFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('arms for nothing when the launcher checkout has a detached HEAD', async () => { + const paths = await fixture() + // A detached launcher names no branch, so there is no branch to protect. + const ctx = await harness(paths.detachedFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('denies when the target and the protected checkout reach one repository through different symlinks', async () => { + const paths = await fixture() + // macOS reaches the temp directory through both `/var/...` and + // `/private/var/...`; a lexical repository comparison would treat the two + // routes as different repositories and fail open on every write. + const link = join(paths.root, 'link') + await symlink(dirname(paths.stagingFile), link, 'dir') + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: join(link, 'file.ts') } }]) + expect(results(agent)[0]?.text).toContain('on branch dsh-staging/20260728T022827Z') + }) + + it('denies a RELATIVE target path resolved against the session workspace', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + // The filesystem tools resolve a relative `file_path` against the session + // cwd, so judging only absolute paths would leave this as an unguarded + // route to the same file. + const agent = await run(ctx, [{ name: 'write', args: { file_path: 'file.ts' } }], dirname(paths.stagingFile)) + expect(results(agent)[0]?.text).toContain('directly is not allowed') + }) + + it('ignores a relative target path when the session names no workspace', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: 'file.ts' } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('ignores a call whose target path is an empty string', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: '' } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it.each([ + ['a non-string file_path', { file_path: 7 }], + ['no file_path at all', { other: 'x' }], + ])('ignores a gated call carrying %s', async (_label, args) => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args }]) + expect(results(agent)[0]?.text).not.toContain('directly is not allowed') + }) + + it('ignores a gated call whose arguments are not JSON, which the loop keeps as raw text', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const callId = CallId('raw') + const adapter = new MockAdapter([ + [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 0, id: callId, name: 'write', argumentsDelta: 'not json' }, + { type: 'block-end', index: 0, block: { type: 'tool-call', id: callId, name: 'write', arguments: 'not json' } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ], + textResponse('done'), + ]) + ctx.llm.registerAdapter(['mock'], adapter) + const agent = ctx.agentLoop.create(SessionId('raw'), { provider: 'mock', model: 'mock' }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) + await waitForIdle(ctx, agent) + expect(results(agent)[0]?.text).not.toContain('directly is not allowed') + }) + + it.each([ + ['a `.git` pointer that names no git directory', 'not a gitdir pointer\n'], + ['an empty `.git` pointer', 'gitdir:\n'], + ['a `.git` pointer into a nonexistent git directory', 'gitdir: /nonexistent/worktrees/x\n'], + ])('allows a write behind %s', async (_label, pointer) => { + const paths = await fixture() + const broken = join(paths.root, 'broken') + await mkdir(broken, { recursive: true }) + await writeFile(join(broken, '.git'), pointer) + const file = join(broken, 'file.ts') + await writeFile(file, 'content\n') + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('denies behind a RELATIVE `.git` pointer, which git resolves against the worktree', async () => { + const paths = await fixture() + // `git worktree add` writes an absolute pointer, but a relocated or + // hand-written one may be relative; git accepts both, so the guard must + // resolve both or it would fail open on a real repository layout. + const relative = join(paths.root, 'relative-pointer') + await mkdir(relative, { recursive: true }) + await writeFile(join(relative, '.git'), 'gitdir: ../master/.git/worktrees/staging-20260728T022827Z\n') + const file = join(relative, 'file.ts') + await writeFile(file, 'content\n') + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }]) + expect(results(agent)[0]?.text).toContain('on branch dsh-staging/20260728T022827Z') + }) + + it('allows a write when the worktree resolves but its HEAD is missing', async () => { + const paths = await fixture() + const gitDir = join(paths.root, 'master', '.git', 'worktrees', 'headless') + await mkdir(gitDir, { recursive: true }) + const headless = join(paths.root, 'headless') + await mkdir(headless, { recursive: true }) + await writeFile(join(headless, '.git'), `gitdir: ${gitDir}\n`) + const file = join(headless, 'file.ts') + await writeFile(file, 'content\n') + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('reuses one resolution for sibling targets in the same directory', async () => { + const paths = await fixture() + const sibling = join(dirname(paths.stagingFile), 'other.ts') + await writeFile(sibling, 'content\n') + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [ + { name: 'write', args: { file_path: paths.stagingFile } }, + { name: 'write', args: { file_path: sibling } }, + ]) + expect(results(agent).map(result => result.isError)).toEqual([true, true]) + }) + + it('protects whichever branch the launcher checkout is on, whatever its name', async () => { + const paths = await fixture() + // The protected branch is read from `protectedCheckout`'s own worktree, so + // a checkout on an unconventional branch name is still protected — a + // hardcoded name pattern would have silently guarded nothing. + const ctx = await harness(paths.taskFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.taskFile } }]) + expect(results(agent)[0]?.text).toContain('on branch task/x') + }) + + it('allows a write in a SIBLING checkout of the same repository on another branch', async () => { + const paths = await fixture() + // A stale staging worktree left by an earlier install shares the + // repository but is not the live deployment, so the workflow rule the + // guard enforces does not apply to it. + const ctx = await harness(paths.siblingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('gates only the configured tools', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile, { tools: ['edit'] }) + const agent = await run(ctx, [ + { name: 'write', args: { file_path: paths.stagingFile } }, + { name: 'edit', args: { file_path: paths.stagingFile } }, + ]) + expect(results(agent).map(result => result.isError)).toEqual([false, true]) + }) +}) + +describe('skill satisfaction', () => { + it('allows the write after a successful load of the required skill in the same turn', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [ + { name: 'skill', args: { name: 'dsh-customize' } }, + { name: 'write', args: { file_path: paths.stagingFile } }, + ]) + expect(results(agent)).toEqual([ + { isError: false, text: 'ok' }, + { isError: false, text: 'ok' }, + ]) + }) + + it('allows the write when the skill load is only in the REPLAYED log, so resume keeps satisfaction', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const seed = priorSkillCalls([{ arguments: JSON.stringify({ name: 'dsh-customize' }) }]) + const agent = await resume(ctx, 'resumed', seed, paths.stagingFile) + expect(results(agent).at(-1)).toEqual({ isError: false, text: 'ok' }) + }) + + it('does not accept a failed skill load', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const seed = priorSkillCalls([{ arguments: JSON.stringify({ name: 'dsh-customize' }), isError: true }]) + const agent = await resume(ctx, 'failed', seed, paths.stagingFile) + expect(results(agent).at(-1)?.isError).toBe(true) + }) + + it('does not accept a different skill', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [ + { name: 'skill', args: { name: 'dsh-upgrade' } }, + { name: 'write', args: { file_path: paths.stagingFile } }, + ]) + expect(results(agent).map(result => result.isError)).toEqual([false, true]) + }) + + it('does not accept a skill call whose arguments are not a JSON object naming a string', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const seed = priorSkillCalls([ + { arguments: 'not json' }, + { arguments: '[]' }, + { arguments: '{"name":7}' }, + { arguments: 'null' }, + ]) + const agent = await resume(ctx, 'malformed', seed, paths.stagingFile) + expect(results(agent).at(-1)?.isError).toBe(true) + }) + + it('honours a configured skill name other than the default', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile, { requiredSkill: 'other-skill' }) + const agent = await run(ctx, [ + { name: 'skill', args: { name: 'other-skill' } }, + { name: 'write', args: { file_path: paths.stagingFile } }, + ]) + expect(results(agent).map(result => result.isError)).toEqual([false, false]) + }) +}) + +describe('non-agent callers', () => { + it('leaves a direct registry call ungated, having no session to replay', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const result = await ctx.tools.execute({ + callId: CallId('direct'), + name: 'write', + arguments: { file_path: paths.stagingFile }, + signal: new AbortController().signal, + }) + expect(result.isError).toBe(false) + }) +}) + +describe('config validation', () => { + it.each([ + ['tools', { tools: [] }, '`tools` must not be empty'], + ['requiredSkill', { requiredSkill: ' ' }, '`requiredSkill` must not be blank'], + ['protectedCheckout', { protectedCheckout: 'relative/path' }, '`protectedCheckout` must be an absolute path'], + ])('rejects an invalid %s at load', async (_field, config, message) => { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(LocalFileSystem, {}) + await expect(ctx.plugin(SourceGuard, config as Config)).rejects.toThrow(message) + }) +}) + +describe('disposal', () => { + it('stops gating once the plugin fiber is disposed', async () => { + const paths = await fixture() + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(LocalFileSystem, {}) + await ctx.plugin(AgentLoop, { agents: [] }) + const fiber = await ctx.plugin(SourceGuard, { protectedCheckout: paths.stagingFile }) + for (const name of ['write', 'skill']) { + ctx.tools.register(defineContentToolFixture({ + name, + description: name, + parameters: { file_path: { type: 'string' }, name: { type: 'string' } }, + async execute() { return [{ type: 'text', text: 'ok' }] }, + })) + } + await fiber.dispose() + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) +}) diff --git a/packages/guard/source-guard/tsconfig.json b/packages/guard/source-guard/tsconfig.json new file mode 100644 index 0000000000..133b7fefdf --- /dev/null +++ b/packages/guard/source-guard/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../fs/fs" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../sandbox/sandbox" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/session-registry/README.i18n.yaml b/packages/session-registry/README.i18n.yaml new file mode 100644 index 0000000000..6eb22cf334 --- /dev/null +++ b/packages/session-registry/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/session-registry/README.md +README.md: c79caccd05d6cbdda0663dd897490fb6004b8250 +README.zh.md: c3fff3bc6b0e0417dd291d129d7fb1001b50258f diff --git a/packages/session-registry/README.md b/packages/session-registry/README.md new file mode 100644 index 0000000000..c79caccd05 --- /dev/null +++ b/packages/session-registry/README.md @@ -0,0 +1,15 @@ +# session-registry/ — live-session registry family + +English | [中文](README.zh.md) + +Which sessions are running right now, readable from a different process. `dsh list-sessions` is the consumer. + +| Package | Role | ctx key | +|---|---|---| +| [`session-registry/`](session-registry/README.md) | The seam: abstract registry service contract and record vocabulary | `ctx.sessionRegistry` | +| [`session-registry-file/`](session-registry-file/README.md) | Backend: one lock-guarded JSON file, pid-derived liveness | — | +| [`session-registry-live/`](session-registry-live/README.md) | Publisher: follows session lifecycle and title events, keeping the registry in step | — | + +The split follows the three-package capability-seam convention: the seam answers "what is live" for a short-lived reader that mounts nothing else, the file backend owns today's medium and can be replaced by a database without touching consumers, and the publisher needs the session store and runs inside a full agent composition. Liveness is derived from the recorded pid at read time rather than stored, so a killed process leaves nothing to clean up. Records carry their own title because log location, format, and compression are per-deployment backend choices an independent reader cannot portably parse. + +This family is independent of session persistence: it records which processes hold which sessions, never conversation content, and a session that is never persisted still lists. diff --git a/packages/session-registry/README.zh.md b/packages/session-registry/README.zh.md new file mode 100644 index 0000000000..c3fff3bc6b --- /dev/null +++ b/packages/session-registry/README.zh.md @@ -0,0 +1,15 @@ +# session-registry/:活跃会话注册表家族 + +[English](README.md) | 中文 + +当前正在运行哪些会话,可以从另一个进程读取。消费方是 `dsh list-sessions`。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| [`session-registry/`](session-registry/README.md) | seam:抽象注册表服务契约与记录词汇 | `ctx.sessionRegistry` | +| [`session-registry-file/`](session-registry-file/README.md) | 后端:单个加锁保护的 JSON 文件、由 pid 推导的存活状态 | — | +| [`session-registry-live/`](session-registry-live/README.md) | 发布方:跟随会话生命周期与标题事件,让注册表保持同步 | — | + +这样拆分遵循由三个包构成的能力 seam 惯例:seam 要回答「哪些会话是活跃的」,供一个不挂载其他任何东西的短生命周期读取方使用;文件后端拥有今天的介质,将来可以换成数据库而不触及消费方;发布方需要会话存储,运行在完整的 agent(智能体)组合体内。存活状态在读取时由记录的 pid 推导,而不是存下来,因此进程被杀掉后不留下任何需要清理的东西。记录自带标题,因为日志位置、格式和压缩都是各部署自行选择的后端方案,独立的读取方无法以可移植的方式解析。 + +这个家族与会话持久化相互独立:它只记录哪些进程持有哪些会话,绝不记录对话内容;从未被持久化的会话同样能被列出。 diff --git a/packages/session-registry/session-registry-file/README.i18n.yaml b/packages/session-registry/session-registry-file/README.i18n.yaml new file mode 100644 index 0000000000..d08b25d86f --- /dev/null +++ b/packages/session-registry/session-registry-file/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/session-registry/session-registry-file/README.md +README.md: b6f29a459e5f7f6484aed9f4968d54f969ca6e73 +README.zh.md: 35861466ed5fb826ee118c506943c3a08024a827 diff --git a/packages/session-registry/session-registry-file/README.md b/packages/session-registry/session-registry-file/README.md new file mode 100644 index 0000000000..b6f29a459e --- /dev/null +++ b/packages/session-registry/session-registry-file/README.md @@ -0,0 +1,42 @@ +# @deepseek-ai/dsh-session-registry-file + +English | [中文](README.zh.md) + +File-backed implementation of the [live-session registry seam](../session-registry/README.md): one lock-guarded JSON file under the Harness home is the whole medium. Mounting it publishes `ctx.sessionRegistry`; `file` exposes the absolute registry path (`/sessions.json`). + +## Liveness and crash safety + +Liveness is derived at read time from the recorded pid via `kill(pid, 0)`: `ESRCH` is dead, `EPERM` is alive under another user, and any other errno propagates rather than being read as an answer. A process killed without running its disposer therefore leaves a record that the next `list()` prunes and rewrites — no daemon, no heartbeat, and no permanent phantom. `bootId` distinguishes a recycled pid, so deregistration cannot delete a namesake record belonging to a different incarnation. + +## Concurrency + +Both layers are required and neither substitutes for the other. + +- **Across processes**, each read-modify-write cycle holds a [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) advisory lock. Unlocked whole-file republication loses records under concurrent launchers, which is why the storage-hub JSON backend — documented last-write-wins, single-host-process — cannot serve this medium. +- **Within one process**, calls queue on an internal chain. The advisory lock is tracked per process, so overlapping same-process callers contend for its bounded retry budget instead of queueing; past roughly a dozen concurrent calls that budget runs out and a registration rejects. Callers publish fire-and-forget, so such a rejection would silently drop a live session from the listing. + +Writes are temp-file plus atomic `rename` (no fsync: a listing lost to a crash is rebuilt by the next process's read, so crash durability buys nothing here), under a `0o700` root with a `0o600` file. + +## Durable format + +`sessions.json` carries a `version` stamp pinned at `0` under the pre-release stance: a differing version is rejected rather than migrated. Reads validate every field because the medium is shared and user-visible. An individually unusable row is dropped while its siblings survive, and unparsable text or a foreign version reads as empty — one malformed record written by another harness version must not hide every other live session. Any of these marks the medium damaged, so the next write republishes and heals it. + +## Config + +| Key | Type | Default | Meaning | +| --- | --- | --- | --- | +| `root` | string | required — no default (a cwd fallback would scatter registries) | Directory holding `sessions.json`; created `0o700` on demand | +| `lockStaleMs` | natural | `10000` | Milliseconds after which a held lock is treated as abandoned and reclaimed | +| `lockRetries` | natural | `10` | Retries before a contended acquisition fails loud | + +## Model Experience + +None, as this package registers no tools, injects no prompts, and appends no session events; it stores host-side process records for the CLI listing surface only. + +#### KV Cache effect + +Independent of live requests: the registry never touches a request prefix, so nothing here can invalidate provider cache reuse. + +## Known Limitations and Deferred Work + +- **A reused pid within the stale window is trusted** — `bootId` distinguishes incarnations of records this process wrote, but a foreign record whose pid the operating system has since reassigned to an unrelated live process is reported alive until its owner removes it. diff --git a/packages/session-registry/session-registry-file/README.zh.md b/packages/session-registry/session-registry-file/README.zh.md new file mode 100644 index 0000000000..35861466ed --- /dev/null +++ b/packages/session-registry/session-registry-file/README.zh.md @@ -0,0 +1,42 @@ +# @deepseek-ai/dsh-session-registry-file + +[English](README.md) | 中文 + +[存活会话注册表 seam](../session-registry/README.md) 的文件后端实现:整套介质就是 Harness home 下的一个加锁保护的 JSON 文件。挂载它即发布 `ctx.sessionRegistry`;`file` 暴露注册表文件的绝对路径(`/sessions.json`)。 + +## 存活状态与崩溃安全 + +存活状态在读取时由记录的 pid 经 `kill(pid, 0)` 推导:`ESRCH` 表示已消亡,`EPERM` 表示存活于另一个用户之下,其他任何 errno 都向外抛出,而不会被当成一个答案来解读。因此,未运行 disposer 就被杀掉的进程留下的记录,会被下一次 `list()` 剪除并重写——不需要 daemon,不需要心跳,也不会有永久残留的幽灵记录。`bootId` 用于区分被复用的 pid,因此注销不会删除属于另一个 incarnation 的同名记录。 + +## 并发 + +两层机制都是必需的,任何一层都无法替代另一层。 + +- **跨进程**:每个读改写周期都持有 [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) 咨询锁。无锁的全文件重发布会在并发启动器下丢失记录,这正是 storage-hub JSON 后端(文档声明 last-write-wins、单宿主进程)无法承担该介质的原因。 +- **进程内**:调用在内部链上排队。咨询锁按进程跟踪,因此同进程的重叠调用者会争用其有限的重试预算而非排队;并发调用超过十来个时预算耗尽,注册会被拒绝。调用方以 fire-and-forget 方式发布,这样的拒绝会静默地把一个存活会话从列表中丢掉。 + +写入采用临时文件加原子 `rename`(不做 fsync:崩溃丢失的列表会被下一个进程的读取重建,崩溃持久性在这里没有收益),根目录 `0o700`,文件 `0o600`。 + +## 持久化格式 + +`sessions.json` 携带一个 `version` 戳,在预发布立场下固定为 `0`:版本不同将被拒绝而非迁移。由于介质是共享且用户可见的,读取会校验每个字段。单条不可用的行会被丢弃而其同伴保留;无法解析的文本或异版本文件读作空——另一个 harness 版本写入的一条损坏记录,不得隐藏所有其他存活会话。上述任一情况都会把介质标记为受损,下一次写入将重新发布并修复它。 + +## 配置 + +| 键 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `root` | string | 必填——无默认值(回退到 cwd 会使注册表散落各处) | 存放 `sessions.json` 的目录;按需以 `0o700` 创建 | +| `lockStaleMs` | natural | `10000` | 持有的锁超过该毫秒数即视为被遗弃并被回收 | +| `lockRetries` | natural | `10` | 锁争用时在明确失败前的重试次数 | + +## 模型体验 + +无。本包不注册工具、不注入提示词、不追加会话事件;它只为 CLI 列表界面存储宿主侧进程记录。 + +#### KV 缓存影响 + +与在途请求无关:注册表从不触碰请求前缀,因此这里不会使提供方缓存复用失效。 + +## 已知限制与后续工作 + +- **陈旧窗口内被复用的 pid 会被信任**——`bootId` 能区分本进程所写记录的 incarnation,但外来记录的 pid 若已被操作系统重新分配给无关的存活进程,在其属主移除之前会一直被报告为存活。 diff --git a/packages/session-registry/session-registry-file/package.json b/packages/session-registry/session-registry-file/package.json new file mode 100644 index 0000000000..321a59902d --- /dev/null +++ b/packages/session-registry/session-registry-file/package.json @@ -0,0 +1,47 @@ +{ + "name": "@deepseek-ai/dsh-session-registry-file", + "description": "Lock-guarded JSON-file backend for the dsh live-session registry seam", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + } + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "proper-lockfile": "^4.1.2", + "schemastery": "^3.15.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-registry": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-registry": "workspace:^", + "@types/proper-lockfile": "^4.1.4", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/session-registry/session-registry-file/src/file.ts b/packages/session-registry/session-registry-file/src/file.ts new file mode 100644 index 0000000000..0c9a5775fb --- /dev/null +++ b/packages/session-registry/session-registry-file/src/file.ts @@ -0,0 +1,97 @@ +/** + * Registry file format: the durable boundary between independent `dsh` + * processes. Every field is validated on read because the medium is shared, + * user-visible, and writable by other harness versions — a foreign or truncated + * file must not crash `dsh list-sessions` into an empty listing that hides live sessions. + * @module @deepseek-ai/dsh-session-registry-file/file + */ + +import { SessionId } from '@deepseek-ai/dsh-session' +import { BootId, type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry' + +/** + * On-disk format version. Pinned at `0` under the pre-release stance: a + * differing version is rejected rather than migrated, matching every other + * harness backend. + */ +export const SESSION_REGISTRY_FORMAT_VERSION = 0 + +/** The complete registry file: a version stamp plus the live records. */ +export interface RegistryFileContents { + /** Format stamp, always {@link SESSION_REGISTRY_FORMAT_VERSION} when written. */ + readonly version: number + /** One record per registered process, in no significant order. */ + readonly records: readonly SessionRegistryRecord[] +} + +/** An empty registry: the value a missing file reads as. */ +export const EMPTY_REGISTRY: RegistryFileContents = { version: SESSION_REGISTRY_FORMAT_VERSION, records: [] } + +/** Narrow an unknown JSON value to a record shape, or reject it as unusable. */ +function parseRecord(value: unknown): SessionRegistryRecord | undefined { + if (typeof value !== 'object' || value === null) return undefined + const row = value as Record + const { sessionId, pid, cwd, startedAt, bootId } = row + if (typeof sessionId !== 'string' || sessionId === '') return undefined + // A non-integer or non-positive pid cannot be probed for liveness. + if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) return undefined + if (typeof cwd !== 'string' || cwd === '') return undefined + if (typeof startedAt !== 'number' || !Number.isSafeInteger(startedAt) || startedAt < 0) return undefined + if (typeof bootId !== 'string' || bootId === '') return undefined + // An absent title is legal (a fresh session has none); a present but + // non-string one is a damaged row rather than a missing optional field. + const { title } = row + if (title !== undefined && typeof title !== 'string') return undefined + return { + sessionId: SessionId(sessionId), + pid, + cwd, + startedAt, + bootId: BootId(bootId), + ...title !== undefined && { title }, + } +} + +/** + * Parse registry file text into records, dropping individually unusable rows. + * + * A row that cannot be interpreted is dropped rather than rejected wholesale: + * one malformed record written by a different harness version must not hide + * every other live session. Unparsable text and a version mismatch yield an + * empty registry for the same reason — the caller republishes the whole file, so + * the next write heals the medium. + * @param text - the raw file contents. + * @returns the records that parsed, and whether the text was fully understood. + */ +export function parseRegistry(text: string): { records: SessionRegistryRecord[]; intact: boolean } { + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + // Swallows only SyntaxError from this one JSON.parse: a torn or foreign + // file heals on the next write, and nothing else can reach this catch. + return { records: [], intact: false } + } + if (typeof parsed !== 'object' || parsed === null) return { records: [], intact: false } + const file = parsed as Record + if (file.version !== SESSION_REGISTRY_FORMAT_VERSION) return { records: [], intact: false } + if (!Array.isArray(file.records)) return { records: [], intact: false } + const records: SessionRegistryRecord[] = [] + let intact = true + for (const row of file.records) { + const record = parseRecord(row) + if (record === undefined) intact = false + else records.push(record) + } + return { records, intact } +} + +/** + * Serialize records as registry file text. + * @param records - the live records to publish. + * @returns pretty-printed JSON with a trailing newline, for a legible medium. + */ +export function serializeRegistry(records: readonly SessionRegistryRecord[]): string { + const file: RegistryFileContents = { version: SESSION_REGISTRY_FORMAT_VERSION, records } + return `${JSON.stringify(file, undefined, 2)}\n` +} diff --git a/packages/session-registry/session-registry-file/src/index.ts b/packages/session-registry/session-registry-file/src/index.ts new file mode 100644 index 0000000000..41e080b79a --- /dev/null +++ b/packages/session-registry/session-registry-file/src/index.ts @@ -0,0 +1,233 @@ +/** + * File-backed live-session registry: one lock-guarded JSON file under the + * Harness home implements the `@deepseek-ai/dsh-session-registry` seam. Every + * operation is a read-modify-write under an advisory lock, because concurrent + * launchers write the same file — the storage-hub JSON backend documents + * last-write-wins for exactly this case and cannot be reused. Liveness is + * derived at read time from the recorded pid. + * @module @deepseek-ai/dsh-session-registry-file + */ + +import { randomUUID } from 'node:crypto' +import { mkdir, readFile, rename, writeFile, open } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import type { Context } from 'cordis' +import lockfile from 'proper-lockfile' +import z from 'schemastery' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { + SessionRegistry, BootId, + type SessionRegistration, type SessionRegistryRecord, +} from '@deepseek-ai/dsh-session-registry' +import { EMPTY_REGISTRY, parseRegistry, serializeRegistry } from './file.ts' +import { isPidAlive } from './liveness.ts' + +export { SESSION_REGISTRY_FORMAT_VERSION, parseRegistry, serializeRegistry } from './file.ts' +export type { RegistryFileContents } from './file.ts' +export { isPidAlive } from './liveness.ts' + +/** The file name holding the registry, relative to {@link Config.root}. */ +export const REGISTRY_FILE_NAME = 'sessions.json' + +/** Default lock staleness threshold; a held lock older than this is reclaimed. */ +const DEFAULT_LOCK_STALE_MS = 10_000 + +/** Default retry budget for a contended lock acquisition. */ +const DEFAULT_LOCK_RETRIES = 10 + +/** + * Plugin config as callers write it: `root` is required — a cwd fallback would + * scatter registries — while the lock tunables are optional because + * `static Config` supplies their defaults. + */ +export interface Config { + /** Directory holding the registry file; created `0o700` on demand. */ + root: string + /** Milliseconds after which a held lock is considered abandoned and reclaimed. */ + lockStaleMs?: number + /** Retries before a contended acquisition fails loud. */ + lockRetries?: number +} + +/** The file-backed {@link SessionRegistry} implementation. */ +export class SessionRegistryFile extends SessionRegistry { + static Config: z = z.object({ + root: z.string().required(), + lockStaleMs: z.natural().default(DEFAULT_LOCK_STALE_MS), + lockRetries: z.natural().default(DEFAULT_LOCK_RETRIES), + }) + + /** Absolute path of the registry file this service reads and writes. */ + readonly file: string + + /** Directory holding {@link file}, created `0o700` on demand. */ + private readonly root: string + + /** Tail of the in-process serialization chain; see {@link mutate}. */ + private chain: Promise = Promise.resolve() + + /** Resolved lock staleness threshold in milliseconds, fixed at construction. */ + private readonly stale: number + + /** Resolved contended-acquisition retry budget, fixed at construction. */ + private readonly retries: number + + constructor(ctx: Context, config: Config) { + super(ctx, BootId(randomUUID())) + this.root = config.root + this.file = join(this.root, REGISTRY_FILE_NAME) + // Resolve the optional tunables here, once: `static Config` supplies these + // same defaults for a Loader mount, and a direct programmatic mount that + // omits them gets them too rather than an undefined lock option. + this.stale = config.lockStaleMs ?? DEFAULT_LOCK_STALE_MS + this.retries = config.lockRetries ?? DEFAULT_LOCK_RETRIES + } + + /** @inheritdoc */ + async register(registration: SessionRegistration): Promise<() => Promise> { + const record: SessionRegistryRecord = { + sessionId: registration.sessionId, + pid: process.pid, + cwd: registration.cwd, + startedAt: Date.now(), + bootId: this.bootId, + ...registration.title !== undefined && { title: registration.title }, + } + await this.mutate(records => [ + ...records.filter(other => other.sessionId !== record.sessionId), + record, + ]) + // The disposer is awaited by Cordis teardown, so the record is durably gone + // before disposal completes rather than racing process exit. A failure here + // is reported, not thrown: the record is already pid-prunable, and an + // unwinding teardown must not be turned into a rejection. + return this.ctx.effect(() => async () => { + try { + await this.mutate(records => records.filter(other => !this.isSelf(other, record))) + } catch (error) { + this.ctx.logger.warn('failed to deregister %s: %s', record.sessionId, String(error)) + } + }) + } + + /** @inheritdoc */ + async retitle(sessionId: SessionId, title: string): Promise { + await this.mutate(records => records.map(record => + record.sessionId === sessionId && record.pid === process.pid && record.bootId === this.bootId + ? { ...record, title } + : record)) + } + + /** @inheritdoc */ + async list(): Promise { + // Pruning is a write, so the read path takes the same lock: a listing that + // observed a half-written file could omit a live session. + return this.mutate(records => [...records]) + } + + /** True when a stored record is this exact registration (pid AND incarnation). */ + private isSelf(candidate: SessionRegistryRecord, self: SessionRegistryRecord): boolean { + return candidate.sessionId === self.sessionId + && candidate.pid === self.pid + && candidate.bootId === self.bootId + } + + /** + * Serialize one read-modify-write cycle against every other cycle in THIS + * process, then run it under the cross-process lock. + * + * Both layers are required and neither substitutes for the other. The advisory + * lock excludes other processes but is tracked per process, so it rejects a + * same-process concurrent acquisition outright (`ELOCKED`) instead of queueing + * — and a composition that creates several sessions at once really does + * overlap these calls. This chain gives those callers a queue; the lock gives + * independent processes exclusion. + */ + private mutate( + change: (records: readonly SessionRegistryRecord[]) => SessionRegistryRecord[], + ): Promise { + // Failures must not poison the chain for later callers, so the tail only + // tracks settlement, never the rejection itself. + const result = this.chain.then(() => this.mutateExclusively(change)) + this.chain = result.then(() => undefined, () => undefined) + return result + } + + /** + * Run one locked read-modify-write cycle: read, prune dead records, apply + * `change`, and republish when the result differs from what was stored. + */ + private async mutateExclusively( + change: (records: readonly SessionRegistryRecord[]) => SessionRegistryRecord[], + ): Promise { + await mkdir(this.root, { recursive: true, mode: 0o700 }) + // proper-lockfile needs the target to exist before it can guard it; an + // exclusive create loses harmlessly to a concurrent launcher doing the same. + await this.ensureFile() + const release = await lockfile.lock(this.file, { + stale: this.stale, + retries: { retries: this.retries, minTimeout: 20, maxTimeout: 500 }, + }) + try { + const before = await this.read() + const live = before.records.filter(record => isPidAlive(record.pid)) + const next = change(live) + // Republish when a record changed or the medium itself was damaged, so a + // foreign or torn file heals instead of being re-parsed on every read. + if (!before.intact || !sameRecords(before.records, next)) await this.write(next) + return next + } finally { + await release() + } + } + + /** Create the registry file if absent, without disturbing existing content. */ + private async ensureFile(): Promise { + try { + const handle = await open(this.file, 'wx', 0o600) + try { + await handle.writeFile(serializeRegistry(EMPTY_REGISTRY.records)) + } finally { + await handle.close() + } + } catch (error) { + // Swallows only EEXIST: another launcher created the file first, which is + // the intended outcome. Every other errno propagates. + /* v8 ignore next -- a non-EEXIST create failure needs a permission or IO fault on a root this cycle just created 0o700. */ + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + } + } + + /** Read and parse the registry file; a missing file reads as empty. */ + private async read(): Promise<{ records: SessionRegistryRecord[]; intact: boolean }> { + // The caller holds the lock, and acquiring it requires the file to exist, so + // a read failure here is real corruption rather than an absent registry and + // propagates: a swallowed error would report "no live sessions" for a medium + // that could not be read. + return parseRegistry(await readFile(this.file, 'utf8')) + } + + /** Publish the complete record set via temp-write plus atomic rename. */ + private async write(records: readonly SessionRegistryRecord[]): Promise { + const temp = join(dirname(this.file), `.${REGISTRY_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`) + await writeFile(temp, serializeRegistry(records), { mode: 0o600 }) + await rename(temp, this.file) + } +} + +/** Compare record lists by identity fields, to decide whether a write is needed. */ +function sameRecords(left: readonly SessionRegistryRecord[], right: readonly SessionRegistryRecord[]): boolean { + if (left.length !== right.length) return false + return left.every((record, index) => { + const other = right[index] + return other !== undefined + && record.sessionId === other.sessionId + && record.pid === other.pid + && record.bootId === other.bootId + && record.cwd === other.cwd + && record.startedAt === other.startedAt + && record.title === other.title + }) +} + +export default SessionRegistryFile diff --git a/packages/session-registry/session-registry-file/src/invariant.ts b/packages/session-registry/session-registry-file/src/invariant.ts new file mode 100644 index 0000000000..9fd175abc4 --- /dev/null +++ b/packages/session-registry/session-registry-file/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-session-registry-file`. + * @module @deepseek-ai/dsh-session-registry-file/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-registry-file' + +/** Cordis companion plugin name. */ +export const name = 'session-registry-file-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the relations a reader must trust (unique live session + * ids, attributable pids) are contract-level and validated by the seam's + * companion around the authoritative `list()`, whatever backend serves it. The + * file medium's own correctness — locking, atomic republication, and + * foreign-row rejection — requires cross-process round-trip tests, not a + * continuously observable in-process relation. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/session-registry/session-registry-file/src/liveness.ts b/packages/session-registry/session-registry-file/src/liveness.ts new file mode 100644 index 0000000000..ff7421edd9 --- /dev/null +++ b/packages/session-registry/session-registry-file/src/liveness.ts @@ -0,0 +1,30 @@ +/** + * Process-liveness probe for stored registry records. + * @module @deepseek-ai/dsh-session-registry-file/liveness + */ + +/** + * Signal-0 probe: report whether a pid currently exists. + * + * `kill(pid, 0)` sends no signal and only tests existence. `ESRCH` means no such + * process. `EPERM` means the process exists but is owned by another user, which + * is still alive — reporting it dead would drop a live record. Any other errno + * is unexpected and propagates rather than being read as a liveness answer. + * @param pid - the operating-system process id to probe. + * @param kill - signal sender, defaulting to `process.kill`; injected by tests. + * @returns whether a process with this pid exists. + */ +export function isPidAlive( + pid: number, + kill: (pid: number, signal: number) => void = process.kill.bind(process), +): boolean { + try { + kill(pid, 0) + return true + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ESRCH') return false + if (code === 'EPERM') return true + throw error + } +} diff --git a/packages/session-registry/session-registry-file/tests/fixtures/register-once.ts b/packages/session-registry/session-registry-file/tests/fixtures/register-once.ts new file mode 100644 index 0000000000..56e05d11fd --- /dev/null +++ b/packages/session-registry/session-registry-file/tests/fixtures/register-once.ts @@ -0,0 +1,27 @@ +/** + * Concurrency-test driver: register one session in a real separate process, + * report readiness on stdout, then stay alive until the parent closes stdin. + * + * Staying alive is load-bearing. The registry prunes records whose process is + * gone, so a driver that exited after writing would be pruned by the next + * writer — the test would then measure pruning instead of the concurrent + * read-modify-write it exists to cover. Argv: ` `. + */ + +import { Context } from 'cordis' +import { SessionId } from '@deepseek-ai/dsh-session' +import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file' + +const [root, sessionId] = process.argv.slice(2) +if (root === undefined || sessionId === undefined) throw new Error('usage: register-once ') + +const ctx = new Context() +await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 60 }) +await ctx.sessionRegistry.register({ sessionId: SessionId(sessionId), cwd: process.cwd() }) +process.stdout.write('registered\n') + +// Hold the process open so its record stays live; the parent ends the run by +// closing stdin, and never disposes the fiber, so no deregistration races the +// parent's read. +process.stdin.resume() +process.stdin.on('end', () => { process.exit(0) }) diff --git a/packages/session-registry/session-registry-file/tests/session-registry-file.spec.ts b/packages/session-registry/session-registry-file/tests/session-registry-file.spec.ts new file mode 100644 index 0000000000..e48bd46385 --- /dev/null +++ b/packages/session-registry/session-registry-file/tests/session-registry-file.spec.ts @@ -0,0 +1,382 @@ +/** + * Tests for the cross-process live-session registry: records survive a + * round-trip, dead pids are pruned, a recycled pid cannot resurrect a foreign + * record, the file format rejects foreign and torn media without hiding live + * sessions, disposal deregisters, and concurrent registrations from independent + * processes all survive (the failure the advisory lock exists to prevent). + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { execFile, spawn } from 'node:child_process' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { SessionId } from '@deepseek-ai/dsh-session' +import { BootId } from '@deepseek-ai/dsh-session-registry' +import SessionRegistryFile, { + REGISTRY_FILE_NAME, + SESSION_REGISTRY_FORMAT_VERSION, + isPidAlive, + parseRegistry, + serializeRegistry, +} from '@deepseek-ai/dsh-session-registry-file' + +const run = promisify(execFile) + +let root: string + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'dsh-session-registry-test-')) +}) +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +/** Mount the service on a fresh Cordis fiber, returning it with its context. */ +async function service(): Promise<{ ctx: Context; registry: SessionRegistryFile }> { + const ctx = new Context() + await ctx.plugin(SessionRegistryFile, { root }) + return { ctx, registry: ctx.sessionRegistry as SessionRegistryFile } +} + +const file = (): string => join(root, REGISTRY_FILE_NAME) + +describe('config resolution', () => { + it('applies the shipped lock defaults when a caller omits them', async () => { + // `ctx.plugin` runs the schema, which fills these in, so the constructor's + // own resolution is reachable only by constructing the service directly — + // the path a programmatic embedder takes. + const ctx = new Context() + const service = new SessionRegistryFile(ctx, { root }) + await service.register({ sessionId: SessionId('defaulted'), cwd: '/w' }) + expect((await service.list()).map(record => record.sessionId)).toEqual(['defaulted']) + await ctx.fiber.dispose() + }) + + it('honors explicitly configured lock tunables', async () => { + const ctx = new Context() + await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 5_000, lockRetries: 3 }) + await ctx.sessionRegistry.register({ sessionId: SessionId('tuned'), cwd: '/w' }) + expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['tuned']) + await ctx.fiber.dispose() + }) +}) + +describe('register and list', () => { + it('publishes a record readable by an independent service instance', async () => { + const first = await service() + await first.registry.register({ sessionId: SessionId('sess-1'), cwd: '/tmp/project' }) + + // A second instance stands in for another process reading the same file. + const reader = await service() + const listed = await reader.registry.list() + expect(listed).toHaveLength(1) + expect(listed[0]).toMatchObject({ + sessionId: 'sess-1', + cwd: '/tmp/project', + pid: process.pid, + }) + await first.ctx.fiber.dispose() + await reader.ctx.fiber.dispose() + }) + + it('replaces an earlier record for the same session id', async () => { + const { ctx, registry } = await service() + await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' }) + await registry.register({ sessionId: SessionId('sess-1'), cwd: '/b' }) + const listed = await registry.list() + expect(listed).toHaveLength(1) + // The later registration wins: `cwd` distinguishes the two calls. + expect(listed[0]?.cwd).toBe('/b') + await ctx.fiber.dispose() + }) + + it('creates the registry root private and the file owner-only', async () => { + const { ctx, registry } = await service() + await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' }) + expect(statSync(root).mode & 0o777).toBe(0o700) + expect(statSync(file()).mode & 0o777).toBe(0o600) + await ctx.fiber.dispose() + }) +}) + +describe('liveness pruning', () => { + it('drops a record whose process is gone', async () => { + const { ctx, registry } = await service() + await registry.register({ sessionId: SessionId('live'), cwd: '/a' }) + + // A real exited pid: spawn a process, wait for it, then claim its id. The + // kernel has reaped it, so signal 0 reports ESRCH. + const dead = await run(process.execPath, ['-e', 'process.stdout.write(String(process.pid))']) + const deadPid = Number(dead.stdout) + expect(isPidAlive(deadPid)).toBe(false) + const stored = parseRegistry(readFileSync(file(), 'utf8')).records + writeFileSync(file(), serializeRegistry([ + ...stored, + { sessionId: SessionId('ghost'), pid: deadPid, cwd: '/b', startedAt: 1, bootId: BootId('boot-x') }, + ])) + + const listed = await registry.list() + expect(listed.map(record => record.sessionId)).toEqual(['live']) + // The prune is durable, not just filtered in memory. + expect(parseRegistry(readFileSync(file(), 'utf8')).records.map(r => r.sessionId)).toEqual(['live']) + await ctx.fiber.dispose() + }) + + it('keeps a live record owned by another user (EPERM means alive)', () => { + const eperm = (): never => { + const error = new Error('operation not permitted') as NodeJS.ErrnoException + error.code = 'EPERM' + throw error + } + expect(isPidAlive(1, eperm)).toBe(true) + }) + + it('propagates an unexpected errno instead of guessing liveness', () => { + const einval = (): never => { + const error = new Error('invalid') as NodeJS.ErrnoException + error.code = 'EINVAL' + throw error + } + expect(() => isPidAlive(1, einval)).toThrow('invalid') + }) +}) + +describe('pid recycling', () => { + it('deregistration removes only this incarnation, not a namesake pid', async () => { + const { ctx, registry } = await service() + const disposer = await registry.register({ sessionId: SessionId('mine'), cwd: '/a' }) + + // A foreign record reusing THIS live pid under a different session and boot + // id: deregistering must not delete it. + const stored = parseRegistry(readFileSync(file(), 'utf8')).records + writeFileSync(file(), serializeRegistry([ + ...stored, + { sessionId: SessionId('other'), pid: process.pid, cwd: '/b', startedAt: 2, bootId: BootId('boot-other') }, + ])) + + // Awaiting the disposer is the contract: the record is durably gone when it + // settles, so the assertion needs no timing slack. + await disposer() + const listed = await registry.list() + expect(listed.map(record => record.sessionId)).toEqual(['other']) + await ctx.fiber.dispose() + }) +}) + +describe('file format', () => { + it('round-trips records', () => { + const records = [{ + sessionId: SessionId('s'), pid: 5 as const, cwd: '/c', startedAt: 7, bootId: BootId('b'), + }] + expect(parseRegistry(serializeRegistry(records))).toEqual({ records, intact: true }) + }) + + it('stamps the format version', () => { + const stamped = JSON.parse(serializeRegistry([])) as { version: number } + expect(stamped.version).toBe(SESSION_REGISTRY_FORMAT_VERSION) + }) + + it.each([ + ['torn json', '{"version":0,"records":[{'], + ['a foreign version', '{"version":99,"records":[]}'], + ['a non-object root', '[]'], + ['a null root', 'null'], + ['a non-array records field', '{"version":0,"records":{}}'], + ])('reads %s as an empty, non-intact registry', (_label, text) => { + expect(parseRegistry(text)).toEqual({ records: [], intact: false }) + }) + + it.each([ + ['a missing session id', { pid: 1, cwd: '/a', startedAt: 0, bootId: 'b' }], + ['a non-integer pid', { sessionId: 's', pid: 1.5, cwd: '/a', startedAt: 0, bootId: 'b' }], + ['a non-positive pid', { sessionId: 's', pid: 0, cwd: '/a', startedAt: 0, bootId: 'b' }], + ['an empty cwd', { sessionId: 's', pid: 1, cwd: '', startedAt: 0, bootId: 'b' }], + ['a negative startedAt', { sessionId: 's', pid: 1, cwd: '/a', startedAt: -1, bootId: 'b' }], + ['a missing boot id', { sessionId: 's', pid: 1, cwd: '/a', startedAt: 0 }], + ['a non-string title', { sessionId: 's', pid: 1, cwd: '/a', startedAt: 0, bootId: 'b', title: 7 }], + ['a non-object row', 'nonsense'], + ])('drops a row with %s but keeps its intact siblings', (_label, row) => { + const good = { sessionId: 'keep', pid: 1, cwd: '/a', startedAt: 0, bootId: 'b' } + const text = JSON.stringify({ version: SESSION_REGISTRY_FORMAT_VERSION, records: [row, good] }) + const parsed = parseRegistry(text) + expect(parsed.records.map(record => record.sessionId)).toEqual(['keep']) + expect(parsed.intact).toBe(false) + }) + + it('heals a damaged medium on the next locked write', async () => { + writeFileSync(file(), 'not json at all') + const { ctx, registry } = await service() + await registry.list() + expect(parseRegistry(readFileSync(file(), 'utf8')).intact).toBe(true) + await ctx.fiber.dispose() + }) + + it('reads a missing file as no live sessions', async () => { + const { ctx, registry } = await service() + rmSync(file(), { force: true }) + expect(await registry.list()).toEqual([]) + await ctx.fiber.dispose() + }) + +}) + +describe('failure reporting', () => { + it('tolerates a registry file another process created first', async () => { + // Two services racing `ensureFile`: the loser sees EEXIST, which is the + // intended outcome rather than an error, and both still publish. + const first = await service() + const second = await service() + await Promise.all([ + first.registry.register({ sessionId: SessionId('a'), cwd: '/a' }), + second.registry.register({ sessionId: SessionId('b'), cwd: '/b' }), + ]) + expect((await first.registry.list()).map(record => record.sessionId).sort()).toEqual(['a', 'b']) + await first.ctx.fiber.dispose() + await second.ctx.fiber.dispose() + }) + + it('warns instead of throwing when deregistration fails during teardown', async () => { + const { ctx, registry } = await service() + await registry.register({ sessionId: SessionId('doomed'), cwd: '/w' }) + // Make the registry path unusable, so the disposer's own write fails while the + // fiber is already unwinding. Teardown must still complete. + rmSync(root, { recursive: true, force: true }) + mkdirSync(join(root, REGISTRY_FILE_NAME), { recursive: true }) + await expect(ctx.fiber.dispose()).resolves.not.toThrow() + }) + + it('propagates a read failure that is not a missing file', async () => { + const { ctx, registry } = await service() + await registry.register({ sessionId: SessionId('sess-1'), cwd: '/w' }) + // A directory where the file belongs makes the read fail with EISDIR, which + // is corruption rather than "no live sessions" and must not read as empty. + rmSync(file(), { force: true }) + mkdirSync(file(), { recursive: true }) + await expect(registry.list()).rejects.toThrow() + rmSync(file(), { recursive: true, force: true }) + await ctx.fiber.dispose() + }) +}) + +describe('retitle', () => { + it('replaces the recorded title of a session this process owns', async () => { + const { ctx, registry } = await service() + await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' }) + expect((await registry.list())[0]?.title).toBeUndefined() + + await registry.retitle(SessionId('sess-1'), 'first') + expect((await registry.list())[0]?.title).toBe('first') + await registry.retitle(SessionId('sess-1'), 'second') + expect((await registry.list())[0]?.title).toBe('second') + await ctx.fiber.dispose() + }) + + it('accepts a registration that already carries a title', async () => { + const { ctx, registry } = await service() + await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a', title: 'preset' }) + expect((await registry.list())[0]?.title).toBe('preset') + await ctx.fiber.dispose() + }) + + it('leaves a same-id record owned by another incarnation untouched', async () => { + const { ctx, registry } = await service() + // Same live pid, different boot id: another incarnation's record must not be + // retitled by this one. + writeFileSync(file(), serializeRegistry([ + { sessionId: SessionId('foreign'), pid: process.pid, cwd: '/b', startedAt: 2, bootId: BootId('boot-other') }, + ])) + await registry.retitle(SessionId('foreign'), 'not mine') + expect((await registry.list())[0]?.title).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('ignores an unknown session id, since a title can resolve after removal', async () => { + const { ctx, registry } = await service() + await expect(registry.retitle(SessionId('never-registered'), 'ghost')).resolves.toBeUndefined() + expect(await registry.list()).toEqual([]) + await ctx.fiber.dispose() + }) +}) + +describe('same-process concurrency', () => { + it('keeps every record when one process registers several sessions at once', async () => { + // The advisory lock is tracked per process, so same-process callers contend + // for it through its bounded retry budget instead of queueing. Past a dozen + // or so overlapping calls that budget runs out and a registration rejects — + // and callers publish fire-and-forget, so the rejection is swallowed and the + // session silently vanishes from the listing. The service therefore + // serializes its own callers; the lock only excludes other processes. + const { ctx, registry } = await service() + // Register once first so the file and directory already exist: without that, + // the concurrent calls serialize behind their own mkdir/create awaits and the + // overlap under test never happens. + await registry.register({ sessionId: SessionId('warm'), cwd: '/w' }) + const settled = await Promise.allSettled(Array.from({ length: 24 }, (_unused, index) => + registry.register({ sessionId: SessionId(`bulk-${String(index)}`), cwd: `/w/${String(index)}` }))) + + // Every call must SUCCEED, not merely leave the file consistent. Callers + // publish fire-and-forget, so a rejection is swallowed and the session + // silently vanishes from the listing rather than failing loudly. + expect(settled.filter(outcome => outcome.status === 'rejected')).toEqual([]) + const expected = [...Array.from({ length: 24 }, (_unused, index) => `bulk-${String(index)}`), 'warm'].sort() + expect((await registry.list()).map(record => record.sessionId).sort()).toEqual(expected) + await ctx.fiber.dispose() + }) + + it('keeps serving later callers after one cycle fails', async () => { + const { ctx, registry } = await service() + // A directory sitting where the registry file must be makes one cycle fail + // without breaking the shared chain for the calls queued behind it. + rmSync(root, { recursive: true, force: true }) + mkdirSync(join(root, REGISTRY_FILE_NAME), { recursive: true }) + await expect(registry.register({ sessionId: SessionId('doomed'), cwd: '/w' })).rejects.toThrow() + + rmSync(root, { recursive: true, force: true }) + await registry.register({ sessionId: SessionId('after'), cwd: '/w' }) + expect((await registry.list()).map(record => record.sessionId)).toEqual(['after']) + await ctx.fiber.dispose() + }) +}) + +describe('cross-process concurrency', () => { + it('keeps every record when independent processes register at once', async () => { + // The regression that motivates the advisory lock: unlocked whole-file + // republication loses records under concurrent writers. Real processes are + // required — same-process promises would serialize on the event loop. + const driver = fileURLToPath(new URL('./fixtures/register-once.ts', import.meta.url)) + const count = 8 + const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) + const tsx = join(repoRoot, 'node_modules/tsx/dist/loader.mjs') + // Source plane: tsx resolves the workspace import through the root + // tsconfig `paths` to `src`, so this runs without a build step. + const env = { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') } + + const children = Array.from({ length: count }, (_unused, index) => + spawn(process.execPath, ['--import', tsx, driver, root, `sess-${String(index)}`], { + env, + stdio: ['pipe', 'pipe', 'inherit'], + })) + try { + // Every child must have committed its record AND still be alive when the + // file is read, so the assertion sees concurrent writes rather than prunes. + await Promise.all(children.map(child => new Promise((resolve, reject) => { + child.stdout.once('data', () => { resolve() }) + child.once('error', reject) + child.once('exit', (code) => { reject(new Error(`driver exited early with ${String(code)}`)) }) + }))) + + const stored = parseRegistry(readFileSync(file(), 'utf8')) + expect(stored.intact).toBe(true) + expect(stored.records.map(record => record.sessionId).sort()).toEqual( + Array.from({ length: count }, (_unused, index) => `sess-${String(index)}`).sort(), + ) + } finally { + for (const child of children) child.stdin.end() + await Promise.all(children.map(child => new Promise((resolve) => { child.once('exit', () => { resolve() }) }))) + } + }, 60_000) +}) diff --git a/packages/session-registry/session-registry-file/tsconfig.json b/packages/session-registry/session-registry-file/tsconfig.json new file mode 100644 index 0000000000..0ab9c6a3a4 --- /dev/null +++ b/packages/session-registry/session-registry-file/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../session-registry" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../../core/session" + } + ] +} diff --git a/packages/session-registry/session-registry-live/README.i18n.yaml b/packages/session-registry/session-registry-live/README.i18n.yaml new file mode 100644 index 0000000000..2d4554f70c --- /dev/null +++ b/packages/session-registry/session-registry-live/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/session-registry/session-registry-live/README.md +README.md: 0404915a97c03999210b0c4e0356cd2cecb2b040 +README.zh.md: 5bfb9a90db2578bfe6517dd9cd3d11ebd043f515 diff --git a/packages/session-registry/session-registry-live/README.md b/packages/session-registry/session-registry-live/README.md new file mode 100644 index 0000000000..0404915a97 --- /dev/null +++ b/packages/session-registry/session-registry-live/README.md @@ -0,0 +1,32 @@ +# @deepseek-ai/dsh-session-registry-live + +English | [中文](README.zh.md) + +Publishes every live session in this process into the [session registry](../session-registry/README.md), so `dsh list-sessions` lists the sessions a server creates on demand rather than only the one a launcher minted up front. + +## Behavior + +Registration follows session lifecycle rather than a launcher-known identity: the plugin publishes every session present at mount and every later `session/created`, and removes a record when its session is disposed. One path therefore serves both the TUI's single session and the browser UI's one-per-conversation sessions. + +A session whose header carries no `cwd` is skipped — the listing's workspace column would have nothing truthful to show. + +`session/title` events are mirrored onto the record through `retitle`, so the latest logged title reaches the listing. Carrying the title in the record is what keeps the reader backend-agnostic: the log's location, file format, and compression are per-deployment choices (the shipped TUI writes zstd-compressed JSONL), so an independent process cannot portably parse one. + +Publication is fire-and-forget with a warning on failure: the registry is an observability aid, so a registry fault must not fail a working agent session. A session that ends while its registration is still in flight leaves a tombstone the completing registration observes, so its record cannot outlive the session until a pid-based prune. + +## Config + +None. Every published record is derived from the session itself, so no deployment-varying choice is left to configure. + +## Model Experience + +None, as this package registers no tools, injects no prompts, and appends no session events; it only mirrors existing lifecycle and title events into a host-side process record. + +#### KV Cache effect + +Independent of live requests: the plugin reads session events and writes a separate registry file without touching any request prefix, so it cannot invalidate provider cache reuse. + +## Known Limitations and Deferred Work + +- **A skipped session is invisible, not deferred** — a session created without a `cwd` is never published, even if a workspace becomes known later; there is no re-check. +- **Title mirroring costs one registry write per revision** — each `session/title` event triggers a locked read-modify-write, so a deployment with an aggressive retitling cadence pays that write per revision. diff --git a/packages/session-registry/session-registry-live/README.zh.md b/packages/session-registry/session-registry-live/README.zh.md new file mode 100644 index 0000000000..5bfb9a90db --- /dev/null +++ b/packages/session-registry/session-registry-live/README.zh.md @@ -0,0 +1,32 @@ +# @deepseek-ai/dsh-session-registry-live + +[English](README.md) | 中文 + +把本进程内每个活跃会话发布到[会话注册表](../session-registry/README.md),因此 `dsh list-sessions` 能列出服务端按需创建的所有会话,而不是只列出启动器一开始铸出的那一个。 + +## 行为 + +注册跟随会话生命周期,而不依赖启动器已知的身份:插件会发布挂载时已存在的每个会话,以及此后每个 `session/created`,并在会话被 dispose(资源释放)时移除对应记录。因此同一条路径既服务 TUI 的单个会话,也服务浏览器 UI 的每对话一个的多个会话。 + +会话头不带 `cwd` 时会被跳过:列表的工作区列拿不到任何真实内容可展示。 + +`session/title` 事件通过 `retitle` 镜像到记录上,因此最新记录的标题能到达列表。把标题带在记录里,正是让读取方与后端无关的原因:日志的位置、文件格式和压缩都是逐部署的选择(随附的 TUI 写入 Zstandard 压缩的 JSONL),因此独立进程无法以可移植的方式解析它。 + +发布是 fire-and-forget,失败只发出警告:注册表是一项可观测性辅助设施,因此注册表故障绝不能让正常工作的 agent(智能体)会话失败。会话在其注册仍在途中时结束,会留下一个 tombstone,让即将完成的注册观测到,因此它的记录不会一直存活到某次基于 pid 的清理才消失。 + +## 配置 + +无。每条发布的记录都从会话本身派生而来,因此没有留下任何逐部署的选择需要配置。 + +## 模型体验 + +无。该包(package)不注册工具、不注入提示词,也不追加会话事件;它只把既有的生命周期事件和标题事件镜像进宿主侧的进程记录。 + +#### KV 缓存影响 + +与实时请求相互独立:该插件读取会话事件,并写入一个独立的注册表文件,不触碰任何请求前缀,因此它无法使提供方 cache 复用失效。 + +## 已知限制与延期工作 + +- **被跳过的会话是不可见,而非延后处理**——创建时不带 `cwd` 的会话永不发布,即使之后工作区变为已知也不会;没有重新检查机制。 +- **标题镜像每次修订都要付出一次注册表写入**——每个 `session/title` 事件都会触发一次加锁的读取、修改和写入,因此改名节奏激进的部署要按修订次数付出这些写入。 diff --git a/packages/session-registry/session-registry-live/package.json b/packages/session-registry/session-registry-live/package.json new file mode 100644 index 0000000000..f0c5aeddc6 --- /dev/null +++ b/packages/session-registry/session-registry-live/package.json @@ -0,0 +1,44 @@ +{ + "name": "@deepseek-ai/dsh-session-registry-live", + "description": "Publishes every live session into the cross-process session registry that `dsh list-sessions` reads", + "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": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-registry": "^0.0.1", + "@deepseek-ai/dsh-session-title": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-registry": "workspace:^", + "@deepseek-ai/dsh-session-registry-file": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/session-registry/session-registry-live/src/index.ts b/packages/session-registry/session-registry-live/src/index.ts new file mode 100644 index 0000000000..8e34487e65 --- /dev/null +++ b/packages/session-registry/session-registry-live/src/index.ts @@ -0,0 +1,84 @@ +/** + * Publishes every live session in this process into the cross-process session + * registry, so `dsh list-sessions` lists sessions a server creates on demand rather than + * only the one a launcher minted up front. + * + * Mounted in a composition whose sessions come and go — the browser UI creates + * one per conversation — this plugin follows `session/created` and + * `session/disposed` instead of registering a single launcher-known identity. + * A session with no `cwd` in its header is skipped: the registry's workspace + * column would have nothing truthful to show, and a subagent child is exactly + * that case. Titles are mirrored into the record as `session/title` events + * arrive, so a reader never has to parse a backend's log format. + * @module @deepseek-ai/dsh-session-registry-live + */ + +import type { Context } from 'cordis' +import type { Session } from '@deepseek-ai/dsh-session' +// Empty type imports carry the Context merges this plugin relies on: the +// `sessionRegistry` service and the `session/title` session event. +import type {} from '@deepseek-ai/dsh-session-registry' +import type {} from '@deepseek-ai/dsh-session-title' + +/** Cordis plugin name. */ +export const name = 'session-registry-live' + +/** Services required before sessions can be followed and records published. */ +export const inject = ['sessions', 'sessionRegistry'] + +/** + * Follow session lifecycle and keep the registry in step. + * @param ctx - context carrying the session store and the registry service. + */ +export function apply(ctx: Context): void { + /** + * Per-session registration state. `'disposing'` is a tombstone written when a + * session ends while its registration is still in flight: without it the + * late-arriving disposer would be stored for a session that no longer exists + * and its record would outlive the session until a pid-based prune. + */ + const registered = new Map Promise) | 'disposing'>() + + const publish = (session: Session): void => { + const cwd = session.header.cwd + // A session without a workspace has no listable location; skipping keeps the + // registry free of rows `dsh list-sessions` could not render truthfully. + if (cwd === undefined) return + void ctx.sessionRegistry.register({ sessionId: session.id, cwd }) + .then((dispose) => { + if (registered.get(session) === 'disposing') { + registered.delete(session) + void dispose() + return + } + registered.set(session, dispose) + }) + .catch((error: unknown) => { + registered.delete(session) + ctx.logger.warn('failed to publish session %s: %s', session.id, String(error)) + }) + } + + for (const session of ctx.sessions.list()) publish(session) + ctx.on('session/created', (session) => { publish(session) }, { global: true }) + ctx.on('session/disposed', (session) => { + const entry = registered.get(session) + if (typeof entry === 'function') { + registered.delete(session) + void entry() + return + } + // Registration is still in flight; leave a tombstone for it to observe. + registered.set(session, 'disposing') + }, { global: true }) + + // Mirror title revisions onto the record. A title arrives after registration + // and may be replaced, so the listing tracks the latest logged value. + ctx.on('session/event', (session, event) => { + if (event.type !== 'session/title') return + const { title } = event.data + void ctx.sessionRegistry.retitle(session.id, title).catch((error: unknown) => { + ctx.logger.warn('failed to retitle %s: %s', session.id, String(error)) + }) + }, { global: true }) +} diff --git a/packages/session-registry/session-registry-live/src/invariant.ts b/packages/session-registry/session-registry-live/src/invariant.ts new file mode 100644 index 0000000000..19d2a9f483 --- /dev/null +++ b/packages/session-registry/session-registry-live/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-session-registry-live`. + * @module @deepseek-ai/dsh-session-registry-live/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-registry-live' + +/** Cordis companion plugin name. */ +export const name = 'session-registry-live-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this plugin owns no durable state of its own — the + * uniqueness and liveness relations over published records are checked by the + * companion in `@deepseek-ai/dsh-session-registry`, which owns that file. + */ +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 */ diff --git a/packages/session-registry/session-registry-live/tests/session-registry-live.spec.ts b/packages/session-registry/session-registry-live/tests/session-registry-live.spec.ts new file mode 100644 index 0000000000..ec0973259e --- /dev/null +++ b/packages/session-registry/session-registry-live/tests/session-registry-live.spec.ts @@ -0,0 +1,227 @@ +/** + * Tests for the live-session publisher over the REAL session store, so + * publication follows the store's actual lifecycle dispatch rather than a + * hand-built event emitter: sessions created after mount are published, + * disposal removes their records, a session without a workspace is skipped, and + * logged title revisions are mirrored onto the record so a reader never parses a + * backend's log format. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import { type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry' +import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file' +import * as live from '@deepseek-ai/dsh-session-registry-live' +// Empty type import carries the `session/title` event into the session-event map. +import type {} from '@deepseek-ai/dsh-session-title' + +let root: string + +beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'dsh-registry-live-test-')) }) +afterEach(() => { + rmSync(root, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +/** Mount the real store plus the publisher. */ +async function mount(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 }) + await ctx.plugin(live) + return ctx +} + +/** Let the publisher's fire-and-forget registration reach durability. */ +const settle = (): Promise => new Promise((resolve) => { setTimeout(resolve, 200) }) + +/** Read the registry through an independent service, as `dsh list-sessions` would. */ +async function listExternally(): Promise { + const reader = new Context() + await reader.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 }) + const records = await reader.sessionRegistry.list() + await reader.fiber.dispose() + return records +} + +describe('publishing', () => { + it('publishes sessions that already exist when the plugin mounts', async () => { + // A composition may mount the publisher after sessions exist (a resumed + // session, or plugin order), so mount-time adoption is its own path. + const ctx = new Context() + await ctx.plugin(SessionStore) + ctx.sessions.create(SessionId('preexisting'), { meta: { cwd: '/work/a' } }) + await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 }) + await ctx.plugin(live) + await settle() + + expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['preexisting']) + await ctx.fiber.dispose() + }) + + it('publishes a session created after mount', async () => { + const ctx = await mount() + ctx.sessions.create(SessionId('later'), { meta: { cwd: '/work/b' } }) + await settle() + + const listed = await ctx.sessionRegistry.list() + expect(listed).toHaveLength(1) + expect(listed[0]).toMatchObject({ sessionId: 'later', cwd: '/work/b' }) + await ctx.fiber.dispose() + }) + + it('skips a session with no workspace, having nothing truthful to list', async () => { + const ctx = await mount() + ctx.sessions.create(SessionId('no-cwd')) + await settle() + expect(await ctx.sessionRegistry.list()).toEqual([]) + await ctx.fiber.dispose() + }) + + it('has no title until one is logged', async () => { + const ctx = await mount() + ctx.sessions.create(SessionId('fresh'), { meta: { cwd: '/work/c' } }) + await settle() + expect((await ctx.sessionRegistry.list())[0]?.title).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('mirrors the latest logged title onto the record', async () => { + const ctx = await mount() + const session = ctx.sessions.create(SessionId('titled'), { meta: { cwd: '/work/d' } }) + await settle() + + session.append('session/title', { title: 'first guess', messageSeqs: [0], source: { kind: 'fallback' } }) + await settle() + expect((await ctx.sessionRegistry.list())[0]?.title).toBe('first guess') + + // A revision replaces the previous value rather than accumulating. + session.append('session/title', { title: 'better title', messageSeqs: [0], source: { kind: 'fallback' } }) + await settle() + expect((await ctx.sessionRegistry.list())[0]?.title).toBe('better title') + await ctx.fiber.dispose() + }) + + it('ignores session events other than a title revision', async () => { + const ctx = await mount() + const session = ctx.sessions.create(SessionId('busy'), { meta: { cwd: '/work/z' } }) + await settle() + const retitle = vi.spyOn(ctx.sessionRegistry, 'retitle') + + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + await settle() + expect(retitle).not.toHaveBeenCalled() + await ctx.fiber.dispose() + }) + + it('retitles only the session that logged the event', async () => { + const ctx = await mount() + const first = ctx.sessions.create(SessionId('one'), { meta: { cwd: '/work/e' } }) + ctx.sessions.create(SessionId('two'), { meta: { cwd: '/work/f' } }) + await settle() + + first.append('session/title', { title: 'only mine', messageSeqs: [0], source: { kind: 'fallback' } }) + await settle() + const byId = new Map((await ctx.sessionRegistry.list()).map(record => [record.sessionId, record.title])) + expect(byId.get(SessionId('one'))).toBe('only mine') + expect(byId.get(SessionId('two'))).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('publishes every concurrently created session', async () => { + const ctx = await mount() + for (let index = 0; index < 5; index += 1) { + ctx.sessions.create(SessionId(`bulk-${String(index)}`), { meta: { cwd: `/work/bulk-${String(index)}` } }) + } + await settle() + expect((await ctx.sessionRegistry.list()).map(record => record.sessionId).sort()) + .toEqual(['bulk-0', 'bulk-1', 'bulk-2', 'bulk-3', 'bulk-4']) + await ctx.fiber.dispose() + }) +}) + +describe('failure and race handling', () => { + it('removes the record when a session is disposed mid-registration', async () => { + // The tombstone path: the session ends before its registration resolves, so + // the late disposer must be applied instead of stored for a dead session. + const ctx = await mount() + let owner: Context | undefined + await ctx.plugin({ + inject: ['sessions'], + apply: (child: Context) => { + owner = child + child.sessions.create(SessionId('raced'), { meta: { cwd: '/work/race' } }) + }, + }) + // No settle: dispose while `register` is still in flight. + await owner?.fiber.dispose() + await settle() + expect(await ctx.sessionRegistry.list()).toEqual([]) + await ctx.fiber.dispose() + }) + + it('warns and drops the record when publication fails', async () => { + const ctx = await mount() + ctx.sessionRegistry.register = () => Promise.reject(new Error('registry offline')) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + + ctx.sessions.create(SessionId('unpublishable'), { meta: { cwd: '/work/x' } }) + await settle() + expect(warn.mock.calls.flat().join(' ')).toMatch(/failed to publish session/) + await ctx.fiber.dispose() + }) + + it('warns when a title revision cannot be recorded', async () => { + const ctx = await mount() + const session = ctx.sessions.create(SessionId('titled'), { meta: { cwd: '/work/y' } }) + await settle() + + ctx.sessionRegistry.retitle = () => Promise.reject(new Error('registry offline')) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + + session.append('session/title', { title: 'doomed', messageSeqs: [0], source: { kind: 'fallback' } }) + await settle() + expect(warn.mock.calls.flat().join(' ')).toMatch(/failed to retitle/) + await ctx.fiber.dispose() + }) +}) + +describe('disposal', () => { + it('removes a record when its own session is disposed, keeping the others', async () => { + const ctx = await mount() + // A session belongs to the fiber that created it, so a child plugin fiber + // gives one session an independent lifetime without disposing the services. + let owner: Context | undefined + await ctx.plugin({ + inject: ['sessions'], + apply: (child: Context) => { + owner = child + child.sessions.create(SessionId('ephemeral'), { meta: { cwd: '/work/e' } }) + }, + }) + ctx.sessions.create(SessionId('durable'), { meta: { cwd: '/work/f' } }) + await settle() + expect(await ctx.sessionRegistry.list()).toHaveLength(2) + + // Disposing only that fiber ends its session, which the publisher follows. + await owner?.fiber.dispose() + await settle() + expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['durable']) + await ctx.fiber.dispose() + }) + + it('leaves no record behind after the whole tree unloads', async () => { + const ctx = await mount() + ctx.sessions.create(SessionId('a'), { meta: { cwd: '/work/g' } }) + ctx.sessions.create(SessionId('b'), { meta: { cwd: '/work/h' } }) + await settle() + expect(await ctx.sessionRegistry.list()).toHaveLength(2) + + await ctx.fiber.dispose() + expect(await listExternally()).toEqual([]) + }) +}) diff --git a/packages/session-registry/session-registry-live/tsconfig.json b/packages/session-registry/session-registry-live/tsconfig.json new file mode 100644 index 0000000000..456adfc51b --- /dev/null +++ b/packages/session-registry/session-registry-live/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../support/invariants" + }, + { + "path": "../../core/session" + }, + { + "path": "../session-registry" + }, + { + "path": "../../session-title/session-title" + } + ] +} diff --git a/packages/session-registry/session-registry/README.i18n.yaml b/packages/session-registry/session-registry/README.i18n.yaml new file mode 100644 index 0000000000..211c8222e8 --- /dev/null +++ b/packages/session-registry/session-registry/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/session-registry/session-registry/README.md +README.md: 8ab8e232e6cd041e5476d4e6cadcb8b64a85f586 +README.zh.md: 62774e8a2a892c97ba6343f12dd35f827ad63e33 diff --git a/packages/session-registry/session-registry/README.md b/packages/session-registry/session-registry/README.md new file mode 100644 index 0000000000..8ab8e232e6 --- /dev/null +++ b/packages/session-registry/session-registry/README.md @@ -0,0 +1,30 @@ +# @deepseek-ai/dsh-session-registry + +English | [中文](README.zh.md) + +Live-session registry seam (`ctx.sessionRegistry`): the contract and record vocabulary for a cross-process registry of the sessions running right now, so a separate short-lived process such as `dsh list-sessions` can answer "what am I running". This package owns no medium — a backend (the lock-guarded JSON file in [`session-registry-file`](../session-registry-file/README.md) today, a database later) implements the abstract service. + +## Shape + +- `register(registration)` — publish `{ sessionId, cwd, title? }` stamped with this process's pid, a per-incarnation `bootId`, and `startedAt`. Replaces any existing record for the same session id. Returns the `ctx.effect` disposer; awaiting it waits for the removal to reach durability. +- `retitle(sessionId, title)` — replace the recorded title of a session **this** process registered. Titles arrive after registration and can be revised, so it is the one mutable field. A record owned by another pid or incarnation is left alone, and an unknown id is a no-op because a title can resolve after the record is gone. +- `list()` — every live record, newest registration last. Liveness is part of the contract, not the backend's discretion: every returned record's process existed at observation time, so a process killed without running its disposer leaves no permanent phantom. + +Backends serialize mutations against concurrent registrars — other processes and overlapping calls in this one — so records are never lost to a torn read-modify-write. + +## Record vocabulary + +`SessionRegistryRecord` carries `sessionId` (unique across live records), `pid`, `cwd`, `startedAt`, a `bootId` distinguishing a recycled pid from the original incarnation, and an optional `title`. The title travels in the record rather than being read from the session log because log location, format, and compression are per-deployment backend choices an independent reader cannot portably parse. + +## Model Experience + +None, as this package registers no tools, injects no prompts, and appends no session events; it defines the host-side listing contract only. + +#### KV Cache effect + +Independent of live requests: the registry never touches a request prefix, so nothing here can invalidate provider cache reuse. + +## Known Limitations and Deferred Work + +- **Records are process-scoped, not agent-scoped** — only top-level launcher surfaces publish. In-process subagents have no process of their own, and out-of-process subagent backends spawn `dsh-jsonrpc-agent` rather than the CLI, so neither appears in a listing. +- **Liveness is pid existence, not health** — a hung or stopped process still lists as running; the contract deliberately makes no judgement about whether a session is making progress. diff --git a/packages/session-registry/session-registry/README.zh.md b/packages/session-registry/session-registry/README.zh.md new file mode 100644 index 0000000000..62774e8a2a --- /dev/null +++ b/packages/session-registry/session-registry/README.zh.md @@ -0,0 +1,30 @@ +# @deepseek-ai/dsh-session-registry + +[English](README.md) | 中文 + +存活会话注册表 seam(`ctx.sessionRegistry`):定义跨进程「当前正在运行哪些会话」注册表的契约与记录词汇,使 `dsh list-sessions` 这类独立的短生命周期进程能够回答「我正在运行什么」。本包不拥有任何介质——由后端实现该抽象服务(今天是 [`session-registry-file`](../session-registry-file/README.md) 中加锁保护的 JSON 文件,将来可以是数据库)。 + +## 形状 + +- `register(registration)`:发布 `{ sessionId, cwd, title? }`,并盖上本进程的 pid、每个 incarnation 独有的 `bootId` 和 `startedAt`。同一会话 id 的既有记录会被替换。返回 `ctx.effect` disposer;await 它即等待移除达到持久性。 +- `retitle(sessionId, title)`:替换**本**进程注册的某个会话的已记录标题。标题在注册之后才到达,并且可以修订,因此它是唯一的可变字段。归属于其他 pid 或其他 incarnation 的记录不受影响;未知 id 为空操作,因为标题可能在记录消失之后才解析出来。 +- `list()`:返回全部存活记录,按注册时间从旧到新排列。存活性属于契约本身,而非后端的自由裁量:每条返回记录的进程在观察时刻都存在,因此未运行 disposer 就被杀掉的进程不会留下永久的幽灵记录。 + +后端必须将变更与并发注册方(其他进程,以及本进程内相互重叠的调用)串行化,使记录不会因撕裂的读改写而丢失。 + +## 记录词汇 + +`SessionRegistryRecord` 携带 `sessionId`(在存活记录中唯一)、`pid`、`cwd`、`startedAt`、用于区分被复用 pid 与原 incarnation 的 `bootId`,以及可选的 `title`。标题随记录传递而非从会话日志读取,因为日志的位置、格式与压缩是各部署后端的选择,独立读取方无法可移植地解析。 + +## 模型体验 + +无。本包不注册工具、不注入提示词、不追加会话事件;它只定义宿主侧的列表契约。 + +#### KV 缓存影响 + +与在途请求无关:注册表从不触碰请求前缀,因此这里不会使提供方缓存复用失效。 + +## 已知限制与后续工作 + +- **记录以进程为粒度,而非以 agent 为粒度**——只有用户直接启动的顶层界面会发布。进程内 subagent 没有自己的进程,进程外 subagent 后端启动的是 `dsh-jsonrpc-agent` 而非本 CLI,两者都不会出现在列表中。 +- **存活性只表示 pid 存在,不表示健康**——挂起或停止的进程仍会被列为运行中;契约刻意不判断会话是否在推进。 diff --git a/packages/session-registry/session-registry/package.json b/packages/session-registry/session-registry/package.json new file mode 100644 index 0000000000..75308999f5 --- /dev/null +++ b/packages/session-registry/session-registry/package.json @@ -0,0 +1,41 @@ +{ + "name": "@deepseek-ai/dsh-session-registry", + "description": "Live-session registry seam for the DeepSeek Harness: contract and record vocabulary", + "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": { + "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/session-registry/session-registry/src/index.ts b/packages/session-registry/session-registry/src/index.ts new file mode 100644 index 0000000000..413317c31c --- /dev/null +++ b/packages/session-registry/session-registry/src/index.ts @@ -0,0 +1,82 @@ +/** + * Live-session registry seam (`ctx.sessionRegistry`): a cross-process registry + * of live `dsh` sessions, so a separate short-lived process such as + * `dsh list-sessions` can answer "what am I running right now". + * + * This package owns only the service contract and the record vocabulary; a + * backend (the lock-guarded JSON file in + * `@deepseek-ai/dsh-session-registry-file` today, a database later) owns the + * medium. Whatever the medium, liveness is part of the contract: {@link list} + * returns only records whose process existed at observation time, so a process + * killed without running its disposer leaves no permanent phantom. + * @module @deepseek-ai/dsh-session-registry + */ + +import { Context, Service } from 'cordis' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { BootId, type SessionRegistryRecord } from './types.ts' + +export { BootId } from './types.ts' +export type { SessionRegistryRecord } from './types.ts' + +declare module 'cordis' { + interface Context { + sessionRegistry: SessionRegistry + } +} + +/** What one process publishes about itself; the service supplies pid and timing. */ +export interface SessionRegistration { + /** The session this process runs. */ + sessionId: SessionId + /** Absolute workspace directory the session acts on. */ + cwd: string + /** Human-readable session title, when one already exists. */ + title?: string +} + +/** + * Cross-process live-session registry. Reads prune dead records, so every + * returned record's process existed at observation time. Backends serialize + * mutations against concurrent registrars — other processes and overlapping + * calls in this one — so records are never lost to a torn read-modify-write. + */ +export abstract class SessionRegistry extends Service { + /** This process incarnation's id, stamped into every record it publishes. */ + protected readonly bootId: BootId + + constructor(ctx: Context, bootId: BootId) { + super(ctx, 'sessionRegistry') + this.bootId = bootId + } + + /** + * Publish this process's record, replacing any stale record for the same + * session id, and prune records whose process is gone. + * @param registration - the session, surface, and workspace to publish. + * @returns the effect disposer that removes this record again; awaiting it + * waits for the removal to reach durability. + */ + abstract register(registration: SessionRegistration): Promise<() => Promise> + + /** + * Replace the recorded title of a session this process registered. + * + * Titles arrive after registration and can be revised, so this is the one + * mutable field. Only a record matching this process and incarnation is + * touched, leaving a same-id record owned by another process alone. An unknown + * session id is a no-op rather than an error: a title can resolve after the + * session's record has already been removed. + * @param sessionId - the session whose recorded title changes. + * @param title - the new title text. + */ + abstract retitle(sessionId: SessionId, title: string): Promise + + /** + * List live sessions, pruning records whose process no longer exists. + * @returns one record per live registered session, newest registration last. + */ + abstract list(): Promise +} + +export default SessionRegistry diff --git a/packages/session-registry/session-registry/src/invariant.ts b/packages/session-registry/session-registry/src/invariant.ts new file mode 100644 index 0000000000..f41207fbed --- /dev/null +++ b/packages/session-registry/session-registry/src/invariant.ts @@ -0,0 +1,58 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-session-registry`. + * @module @deepseek-ai/dsh-session-registry/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { SessionRegistryRecord } from './types.ts' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-registry' + +/** Cordis companion plugin name. */ +export const name = 'session-registry-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * Cross-check every published listing against the relations the seam contract + * owns: a session id identifies at most one live record, and each listed record + * carries the identity fields a reader must be able to trust. Only a backend's + * mutation path can break either, so the check wraps the authoritative read + * rather than inspecting any medium. + * + * Liveness itself is deliberately not re-probed here. A backend derives it at + * read time, so a second probe would race the first and report a process that + * exited in between as a violation of a contract the seam never made. + */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + const service = ctx.sessionRegistry + const listed = service.list.bind(service) + ctx.effect(() => { + service.list = async (): Promise => { + const records = await listed() + const seen = new Set() + for (const record of records) { + if (seen.has(record.sessionId)) { + fail(`session ${record.sessionId} appears in more than one live registry record`) + } + seen.add(record.sessionId) + // A record a reader cannot attribute to a process is unusable: `dsh list-sessions` + // renders the pid and derives liveness from it. + if (!Number.isSafeInteger(record.pid) || record.pid <= 0) { + fail(`listed session ${record.sessionId} carries unusable pid ${String(record.pid)}`) + } + } + return records + } + return () => { service.list = listed } + }) +}, { inject: ['sessionRegistry'] }) + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/session-registry/session-registry/src/types.ts b/packages/session-registry/session-registry/src/types.ts new file mode 100644 index 0000000000..6fff91648e --- /dev/null +++ b/packages/session-registry/session-registry/src/types.ts @@ -0,0 +1,55 @@ +/** + * Registry record vocabulary: the durable shape one live `dsh` process + * publishes about itself and `dsh list-sessions` reads back. + * @module @deepseek-ai/dsh-session-registry/types + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { SessionId } from '@deepseek-ai/dsh-session' + +/** + * Identifies one process incarnation. Minted per registering process, so a + * record whose `pid` was recycled by the operating system cannot be mistaken + * for the original: the boot id differs even when the pid matches. + */ +export type BootId = Branded<'BootId'> + +/** + * Brand a string as a {@link BootId}. + * @param id - the raw boot id string. + * @returns the same string, branded (a compile-time cast — no runtime cost). + */ +export function BootId(id: string): BootId { + return id as BootId +} + +/** + * One live session's self-published registration. Every field is immutable for + * the lifetime of the registration: a process publishes once at startup and + * removes the record on exit, never mutating it in place. + * + * Only top-level surfaces a user starts directly register: in-process subagents + * have no process of their own, and out-of-process subagent backends spawn + * `dsh-jsonrpc-agent` rather than this CLI, so neither can reach the registry. + */ +export interface SessionRegistryRecord { + /** The session this process is running. Unique across live records. */ + readonly sessionId: SessionId + /** Operating-system process id, used with `bootId` to decide liveness. */ + readonly pid: number + /** Absolute workspace directory the session acts on. */ + readonly cwd: string + /** Non-negative safe-integer Unix epoch milliseconds when the process registered. */ + readonly startedAt: number + /** This process incarnation's id, distinguishing a recycled `pid`. */ + readonly bootId: BootId + /** + * Human-readable session title, as the registering process last knew it. + * + * Carried in the record rather than read from the session log: the log's + * location, file format, and compression are per-deployment backend choices, + * so an independent reader cannot portably parse one. Absent until a title + * exists — a fresh session has none. + */ + readonly title?: string +} diff --git a/packages/session-registry/session-registry/tests/invariant.spec.ts b/packages/session-registry/session-registry/tests/invariant.spec.ts new file mode 100644 index 0000000000..6d97b3d129 --- /dev/null +++ b/packages/session-registry/session-registry/tests/invariant.spec.ts @@ -0,0 +1,98 @@ +/** + * Tests for the registry's invariant companion: each acceptance path is proven + * to REJECT an invalid case, since a check that cannot fail is not a check. + * The backend is a minimal in-memory stub — the companion owns contract-level + * relations over `list()` results, whatever medium serves them. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import InvariantService from '@deepseek-ai/dsh-invariants' +import { SessionId } from '@deepseek-ai/dsh-session' +import { BootId, SessionRegistry, type SessionRegistration, type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry' +import * as invariant from '@deepseek-ai/dsh-session-registry/src/invariant.ts' + +/** Minimal in-memory backend whose listings the test scripts directly. */ +class StubRegistry extends SessionRegistry { + records: SessionRegistryRecord[] = [] + + constructor(ctx: Context) { + super(ctx, BootId('stub-boot')) + } + + register(registration: SessionRegistration): Promise<() => Promise> { + this.records.push({ + sessionId: registration.sessionId, + pid: process.pid, + cwd: registration.cwd, + startedAt: Date.now(), + bootId: this.bootId, + }) + return Promise.resolve(() => Promise.resolve()) + } + + retitle(): Promise { + return Promise.resolve() + } + + list(): Promise { + return Promise.resolve([...this.records]) + } +} + +/** One record with the given identity fields, live by construction. */ +function record(sessionId: string, boot: string, pid = process.pid): SessionRegistryRecord { + return { sessionId: SessionId(sessionId), pid, cwd: '/w', startedAt: 1, bootId: BootId(boot) } +} + +/** Mount the stub backend, optionally seeding records before the companion wraps `list`. */ +async function mount(records?: SessionRegistryRecord[]): Promise<{ ctx: Context; stub: StubRegistry }> { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await ctx.plugin(StubRegistry) + const stub = ctx.sessionRegistry as StubRegistry + if (records !== undefined) stub.records = records + await ctx.plugin(invariant) + return { ctx, stub } +} + +describe('listing invariants', () => { + it('accepts a well-formed listing', async () => { + const { ctx } = await mount() + await ctx.sessionRegistry.register({ sessionId: SessionId('ok'), cwd: '/w' }) + await expect(ctx.sessionRegistry.list()).resolves.toHaveLength(1) + await ctx.fiber.dispose() + }) + + it('rejects a listing where one session id appears twice', async () => { + // Two live records for one session: only a broken mutation path (or an + // out-of-band writer) can produce this, and it would make + // `dsh list-sessions` show one session twice. + const { ctx } = await mount([record('dup', 'boot-a'), record('dup', 'boot-b')]) + await expect(ctx.sessionRegistry.list()).rejects.toThrow(/appears in more than one live registry record/) + await ctx.fiber.dispose() + }) + + it('rejects a listing whose record carries an unusable pid', async () => { + // A record no reader could attribute to a process: `dsh list-sessions` + // renders the pid and derives liveness from it. + const { ctx } = await mount([record('ghost', 'boot-x', 0)]) + await expect(ctx.sessionRegistry.list()).rejects.toThrow(/carries unusable pid/) + await ctx.fiber.dispose() + }) + + it('stops checking, and keeps working, when the companion unloads', async () => { + // A duplicate-id listing the mounted companion rejects, so the post-disposal + // read proves the wrapper is gone rather than merely bypassed. + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await ctx.plugin(StubRegistry) + ;(ctx.sessionRegistry as StubRegistry).records = [record('dup', 'boot-a'), record('dup', 'boot-b')] + const companion = await ctx.plugin(invariant) + await expect(ctx.sessionRegistry.list()).rejects.toThrow(/appears in more than one/) + + await companion.dispose() + await expect(ctx.sessionRegistry.list()).resolves.toHaveLength(2) + await ctx.fiber.dispose() + }) +}) diff --git a/packages/session-registry/session-registry/tsconfig.json b/packages/session-registry/session-registry/tsconfig.json new file mode 100644 index 0000000000..cca8f9282b --- /dev/null +++ b/packages/session-registry/session-registry/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../support/invariants" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../core/session" + } + ] +} diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index 3c59b2ba93..be6b79d5c5 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/support/acp-snapshot/README.md -README.md: 43666d2d117170f9d7f73737fb1704efc2a55f27 -README.zh.md: 608b2490f5de7dc7ec4ecd863f41df7f609451ac +README.md: a119656ce09863f3025f6f2ec7ffbedfb3bca4ca +README.zh.md: 736f34fec83d48b3d783b63e7bab0dec44fe1676 diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 43666d2d11..a119656ce0 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -51,7 +51,7 @@ defineAcpSnapshotSuite({ }) ``` -A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Default generated workspaces are stored in session fixtures as `{{cwd}}` so platform temp roots and random basenames do not affect recordings; `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test, keeps that explicit path in the fixture, and remains parent-owned while the harness removes only the generated child. A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes. +A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Default generated workspaces are stored in session fixtures as `{{cwd}}` so platform temp roots and random basenames do not affect recordings; `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test, keeps that explicit path in the fixture, and remains parent-owned while the harness removes only the generated child. `prepareCwd` runs after the `workspace/` fixture is copied and before the child boots, for world state a committed fixture cannot carry: git never tracks an entry named `.git`, and `.gitignore` excludes every `worktrees/` directory, so a repository-shaped scenario commits the file bodies under representable names and the hook assembles the real layout from them. A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes. Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario whose driven behavior needs POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere. diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index 608b2490f5..736f34fec8 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -51,7 +51,7 @@ defineAcpSnapshotSuite({ }) ``` -启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。默认生成的 workspace 在会话 fixture 中存储为 `{{cwd}}`,使平台临时根目录和随机 basename 不影响录制结果;当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域,在 fixture 中保留该显式路径,并仍归父级所有,而 harness 只移除生成的子级。每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource` 和 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。 +启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。默认生成的 workspace 在会话 fixture 中存储为 `{{cwd}}`,使平台临时根目录和随机 basename 不影响录制结果;当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域,在 fixture 中保留该显式路径,并仍归父级所有,而 harness 只移除生成的子级。`prepareCwd` 在复制 `workspace/` fixture 后、子级启动前运行,用于提供已提交 fixture 无法承载的环境状态:Git 永远不会跟踪名为 `.git` 的条目,且 `.gitignore` 会排除所有 `worktrees/` 目录,因此仓库形态的场景会以可表示的名称提交文件内容,并由该钩子据此组装真实布局。每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource` 和 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。 每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。驱动行为需要 POSIX 进程语义的场景(例如取消实时 bash 调用会终止脱离进程组)声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件。 diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index d11a4f1b9c..56df34e80f 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -155,6 +155,13 @@ export interface RunOptions { * start from an empty workspace. */ workspaceDir?: string + /** + * Optional setup run in the generated cwd after {@link workspaceDir} is + * copied and before the child boots — for world state a committed fixture + * cannot express, such as a `.git` entry (git never tracks that name, so a + * repository-shaped fixture has to be materialized at run time). + */ + prepareCwd?: (cwd: string) => Promise /** * Parent directory for the generated session cwd. Defaults to * `os.tmpdir()`. A scenario that must distinguish its workspace from the @@ -221,6 +228,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise if (opts.workspaceDir !== undefined && existsSync(opts.workspaceDir)) { await cp(opts.workspaceDir, cwd, { recursive: true }) } + await opts.prepareCwd?.(cwd) const env: NodeJS.ProcessEnv = { ...opts.env, DSH_SNAPSHOT: opts.mode, diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index d04a481993..efc1165c45 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -130,6 +130,12 @@ export interface Scenario { * test and the scenario needs an independent project location. */ workspaceParent?: string + /** + * Setup run in the generated cwd after the `workspace/` fixture is copied and + * before the child boots, for world state a committed fixture cannot express + * — a `.git` entry, which git never tracks under that name. + */ + prepareCwd?: (cwd: string) => Promise /** * Whether Windows additionally compares stdout with native separators against * `stdout.expected.windows.jsonl`. The shared canonical stdout expected output is still @@ -981,6 +987,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { ...!RECORDING && childFixtureFiles.length > 0 ? { childFiles: childFixtureFiles.map(file => join(dir, file)) } : {}, ...existsSync(workspaceDir) ? { workspaceDir } : {}, ...scenario.workspaceParent !== undefined ? { workspaceParent: scenario.workspaceParent } : {}, + ...scenario.prepareCwd !== undefined ? { prepareCwd: scenario.prepareCwd } : {}, // A scenario booting an overlay tree passes its own live config; the // bin's replay swap derives the sibling `*cordis.snapshot.yml` from it. ...scenario.configPath !== undefined ? { configPath: scenario.configPath } : {}, diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/stdout.expected.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/stdout.expected.jsonl index d0242ae39f..bed4c47530 100644 --- a/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/stdout.expected.jsonl +++ b/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":false}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"thinking about it"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"workspace:seed.txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"workspace:prepared.marker,seed.txt"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 420dcef2cf..a8dec942ae 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -1,5 +1,5 @@ import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { rm } from 'node:fs/promises' +import { rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -78,6 +78,7 @@ const REPLAY_SCENARIOS: Scenario[] = [ env: { DSH_PERMISSION_MODE: 'never' }, configPath: AGENT.configPath, workspaceParent: tmpdir(), + prepareCwd: async (cwd) => { await writeFile(join(cwd, 'prepared.marker'), 'prepared\n') }, }, { name: 'no-model', hasModelTurn: false, recorded: false, headerClass: 'main' }, { name: 'blocked-log', hasModelTurn: false, comparesLog: true, recorded: false, headerClass: 'main' }, diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 08a274fd25..d58dfc1575 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md -README.md: 0282d3e9559d55c3fe5b07df133747750c06ebad -README.zh.md: b7121bbd288cd6e3f9ef2301de6018ceb380eb06 +README.md: 2ccf03a2b30a416334e3b8dbe806875019213a31 +README.zh.md: 15c13adad5064df5e882f55dbef367c146376c6d diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 0282d3e955..2ccf03a2b3 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -11,8 +11,7 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c | `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 | | `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 | +| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount (`prepare` is where a bin provides launcher-owned context slots a mounted app reads, such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount the Loader/include tree, await it, assert entries loaded, 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 | diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index b7121bbd28..15c13adad5 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -11,8 +11,7 @@ | `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) | | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | | `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` 表达式中读取它,因此恢复操作无需环境变量 | +| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,在插件挂载前执行可选的宿主准备操作(`prepare` 正是 bin 提供由启动器拥有、供已挂载应用读取的上下文插槽之处,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载 Loader/include 树并等待其结算,断言所有条目均已加载,最后返回根上下文 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 4644912304..9882d22603 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -156,17 +156,6 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void { } } -/** - * 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`). - */ -export const RESUME_SESSION_ID_KEY = 'resumeSessionId' - /** * Boot the Loader against `absoluteConfigPath` and return only after the whole * tree settles. Entry names load through the Loader's internal module loader diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70de732701..e3be18db21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -278,6 +278,15 @@ importers: '@deepseek-ai/dsh-session-projection-cache': specifier: workspace:^ version: link:../../packages/session-projection/session-projection-cache + '@deepseek-ai/dsh-session-registry': + specifier: workspace:^ + version: link:../../packages/session-registry/session-registry + '@deepseek-ai/dsh-session-registry-file': + specifier: workspace:^ + version: link:../../packages/session-registry/session-registry-file + '@deepseek-ai/dsh-session-registry-live': + specifier: workspace:^ + version: link:../../packages/session-registry/session-registry-live '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../packages/session-title/session-title @@ -454,6 +463,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:* version: link:../packages/ui/app-boot + '@deepseek-ai/dsh-bash': + specifier: workspace:* + version: link:../packages/bash/bash '@deepseek-ai/dsh-bash-local': specifier: workspace:* version: link:../packages/bash/bash-local @@ -553,6 +565,9 @@ importers: '@deepseek-ai/dsh-session-title-first-message-llm': specifier: workspace:* version: link:../packages/session-title/session-title-first-message-llm + '@deepseek-ai/dsh-source-guard': + specifier: workspace:* + version: link:../packages/guard/source-guard '@deepseek-ai/dsh-spill-local': specifier: workspace:* version: link:../packages/spill/spill-local @@ -586,6 +601,9 @@ importers: '@deepseek-ai/dsh-timeout-policy': specifier: workspace:* version: link:../packages/timeout/timeout-policy + '@deepseek-ai/dsh-tmux-context': + specifier: workspace:* + version: link:../packages/context/tmux-context '@deepseek-ai/dsh-token-meter': specifier: workspace:* version: link:../packages/llm/token-meter @@ -1883,6 +1901,37 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/context/tmux-context: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../../bash/bash + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/context/workspace-context: dependencies: schemastery: @@ -2742,6 +2791,49 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/guard/source-guard: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../../fs/fs + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../../fs/fs-local + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/hooks/hook-protocol: devDependencies: '@deepseek-ai/dsh-bash': @@ -3890,6 +3982,70 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/session-registry/session-registry: + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/session-registry/session-registry-file: + dependencies: + proper-lockfile: + specifier: ^4.1.2 + version: 4.1.2 + schemastery: + specifier: ^3.15.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-registry': + specifier: workspace:^ + version: link:../session-registry + '@types/proper-lockfile': + specifier: ^4.1.4 + version: 4.1.4 + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/session-registry/session-registry-live: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-registry': + specifier: workspace:^ + version: link:../session-registry + '@deepseek-ai/dsh-session-registry-file': + specifier: workspace:^ + version: link:../session-registry-file + '@deepseek-ai/dsh-session-title': + specifier: workspace:^ + version: link:../../session-title/session-title + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/session-title/session-title: dependencies: schemastery: @@ -8000,6 +8156,9 @@ packages: '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/proper-lockfile@4.1.4': + resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + '@types/react-dom@18.3.7': resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} peerDependencies: @@ -9103,6 +9262,9 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} @@ -10090,6 +10252,9 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} @@ -10193,6 +10358,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} @@ -10334,6 +10503,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -12903,6 +13075,10 @@ snapshots: '@types/prop-types@15.7.15': {} + '@types/proper-lockfile@4.1.4': + dependencies: + '@types/retry': 0.12.0 + '@types/react-dom@18.3.7(@types/react@18.3.31)': dependencies: '@types/react': 18.3.31 @@ -14204,6 +14380,8 @@ snapshots: gopd@1.2.0: {} + graceful-fs@4.2.11: {} + hachure-fill@0.5.2: {} handlebars@4.7.9: @@ -15384,6 +15562,12 @@ snapshots: process-nextick-args@2.0.1: {} + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + property-information@7.2.0: {} protobufjs@7.6.4: @@ -15533,6 +15717,8 @@ snapshots: resolve-pkg-maps@1.0.0: {} + retry@0.12.0: {} + retry@0.13.1: {} rfdc@1.4.1: {} @@ -15769,6 +15955,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} sisteransi@1.0.5: {} diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 0f3270f8ee..90a5bef153 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -229,6 +229,8 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = { DirectoryPickerCapability: 'picker interaction contract is owned by packages/host/directory-picker/README.md', CreateAgentOptions: 'agent creation contract is owned by packages/core/agent/README.md', Domain: 'domain interface is owned by packages/storage/storage-domain/README.md', + SessionRegistration: 'registry publication input is owned by packages/session-registry/session-registry/README.md', + SessionRegistryRecord: 'live-session record vocabulary is owned by packages/session-registry/session-registry/README.md', DomainChanged: 'event-local snapshot is owned by packages/storage/storage-domain/src/events.ts', DomainFacility: 'domain form facility is owned by packages/storage/storage-domain/README.md', DomainImpl: 'domain implementation contract is owned by packages/storage/storage-domain/README.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 8846cca3eb..6df0c9acee 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -171,6 +171,15 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['apiproxy'], note: 'Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections.', }, + { + key: 'sessionRegistry', + pkg: 'session-registry', + title: 'Live-session registry', + mode: 'seam', + implementations: ['session-registry-file'], + consumers: ['session-registry-live'], + note: 'Seam contract for live-session records; the file backend owns the lock-guarded medium, liveness is derived from the recorded pid at read time, and the publisher mirrors lifecycle and title events for `dsh list-sessions`.', + }, { key: 'sessionQuery', pkg: 'session-query', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index fd74c6d13e..58ac34a3cf 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -98,6 +98,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/session-projection/session-projection': { kind: 'none', reason: 'The projection registry serves client-facing read models of already-logged session state and registers no model surface.' }, 'packages/session-projection/session-projection-cache': { kind: 'none', reason: 'The persisted cache accelerates host-side cold reads of projection state and registers no model surface.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, + 'packages/session-registry/session-registry': { kind: 'none', reason: 'The seam defines the host-side listing contract and registers no model surface.' }, + 'packages/session-registry/session-registry-file': { kind: 'none', reason: 'The file backend stores host-side process records for the CLI listing surface and registers no model surface.' }, + 'packages/session-registry/session-registry-live': { kind: 'none', reason: 'The publisher mirrors lifecycle and title events into host-side process records and registers no model surface.' }, 'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' }, 'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' }, 'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' }, diff --git a/skills/dsh-migrate/SKILL.md b/skills/dsh-migrate/SKILL.md new file mode 100644 index 0000000000..fbea31f04f --- /dev/null +++ b/skills/dsh-migrate/SKILL.md @@ -0,0 +1,57 @@ +--- +name: dsh-migrate +description: Migrate a user's setup from another coding agent (opencode, pi, Claude Code, Codex) to DSH — porting instruction files, custom commands and skills, hooks, MCP servers, and API/env configuration into their DSH equivalents. Use when the user asks to migrate, switch, or move from another coding agent to DSH. +--- + +# DSH Migrate + +Move a user's existing coding-agent setup onto DSH: instruction files, custom commands, skills, hooks, MCP servers, and API/environment configuration. Port only what has a real DSH equivalent; tell the user plainly when something has none. + +## First: identify the source + +Ask which agent the user is migrating from if they have not said: **opencode**, **pi**, **Claude Code**, or **Codex**. The mapping differs per source. Then locate that agent's config (ask the user, or inspect the obvious locations: `~/.claude/` and `.claude/` for Claude Code, `~/.codex/` and `.codex/` for Codex, the opencode/pi config dir the user names). Read what exists before proposing changes; never invent files the user does not have. + +## DSH targets + +Every migration lands in one of these DSH surfaces. Verify the exact path against the running install rather than assuming. + +- **Workspace instructions**: DSH reads `AGENTS.md` and `CLAUDE.md` (and `AGENTS.local.md` / `CLAUDE.local.md`) from the project, walking up to the project root, plus a user-global `~/.dsh/AGENTS.md`. `CLAUDE.md` is read as-is, so a Claude Code project needs no rename. +- **Personal overlay** (user-global, applies to every DSH session): the Harness home `~/.dsh/` holds `config.yaml` (a top-level YAML array of Loader patch entries that patch the booted plugin tree), `.env` (fills environment gaps only — ambient env and the invoking directory's `.env` win), `AGENTS.md`, and `skills/`. +- **Skills**: directory-bundle or flat-Markdown skills load from `.dsh/skills/` and `.agents/skills/` in the project, and `~/.dsh/skills/` and `~/.agents/skills/` for the user. Personal skills go in `~/.dsh/skills//SKILL.md`. Use the `skill-creator` skill to author them. +- **Hooks**: DSH runs a mapped subset of an existing Claude Code or Codex hook config through compatibility bridges — no rewrite needed for the supported subset. See the per-source sections. +- **MCP servers**: DSH has no native MCP client. Reach MCP servers through the `mcporter` skill / CLI, which can call servers already configured for other tools. +- **API / model config**: DSH uses `DEEPSEEK_API_KEY` (and optional `DEEPSEEK_BASE_URL`) from `.env` (root, invoking directory, or `~/.dsh/.env`). Model and provider are chosen in the booted `cordis.yml` / personal overlay, not per-provider config files. + +## Per-source mapping + +### Claude Code + +- `CLAUDE.md` → read as-is by DSH workspace instructions; keep it, or consolidate into `AGENTS.md`. User-global rules → `~/.dsh/AGENTS.md`. +- `.claude/hooks.json` (or a settings file's `hooks` key) → the `@deepseek-ai/dsh-hooks-claude` bridge runs the mapped command-hook subset on DSH's interception seams, with `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PROJECT_DIR}` substitution. Add it to the booted `cordis.yml` (or personal overlay) pointing `configPath` at the existing file. Anything outside the mapped subset should become a native DSH plugin, not a shimmed hook. +- Slash commands → DSH commands are plugin-provided; there is no drop-in import. Reimplement genuinely needed ones as skills (`~/.dsh/skills/`) or plugins. +- MCP servers in Claude config → use `mcporter` to reach them; DSH has no native MCP. +- `ANTHROPIC_API_KEY` etc. do not transfer; DSH is DeepSeek-backed via `DEEPSEEK_API_KEY`. + +### Codex + +- Codex `AGENTS.md` → DSH already reads `AGENTS.md`; keep it. User-global → `~/.dsh/AGENTS.md`. +- Codex hook config → the `@deepseek-ai/dsh-hooks-codex` bridge runs a deliberate subset (`PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, `Stop`; regex-only matchers; no plugin env injection; no pre-tool approval/rewrite). Add the bridge to the booted config with `configPath` at the existing Codex hooks file. State the unsupported points to the user rather than implying full parity. +- MCP servers → `mcporter`. +- API/env → `DEEPSEEK_API_KEY` in `.env`. + +### opencode / pi + +- These have no compatibility bridge. Port by concept, not by file: + - Agent/system instructions → `AGENTS.md` (project) and `~/.dsh/AGENTS.md` (user-global). + - Provider/model and any plugin-style tuning → the booted `cordis.yml` or `~/.dsh/config.yaml` overlay patches; API keys → `.env`. + - Reusable prompts/commands → skills under `~/.dsh/skills/`. + - MCP servers → `mcporter`. +- pi has no native MCP by design; the `mcporter` route is the same as for DSH. + +## Do the migration + +1. Confirm the source agent and read its actual config. +2. For each capability (instructions, hooks, commands/skills, MCP, API/env), map it to the DSH target above, or tell the user it has no equivalent. +3. Write the ported files (`AGENTS.md`, `~/.dsh/AGENTS.md`, `~/.dsh/config.yaml`, `~/.dsh/.env`, skills). For hook bridges, add the plugin entry to the booted config. +4. Verify: hooks need the bridge plugin present in the running tree; MCP needs `mcporter` reachable; API needs `DEEPSEEK_API_KEY` set. Test in a real DSH session, not just on paper. +5. Summarize what was ported, what was reimplemented, and what has no DSH equivalent. diff --git a/tsconfig.base.json b/tsconfig.base.json index ca4fc68b58..62033becd0 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -87,6 +87,7 @@ "./packages/session-persistence/*/src/invariant.ts", "./packages/session-projection/*/src/invariant.ts", "./packages/session-query/*/src/invariant.ts", + "./packages/session-registry/*/src/invariant.ts", "./packages/telemetry/*/src/invariant.ts", "./packages/acp/*/src/invariant.ts", "./packages/storage/*/src/invariant.ts", @@ -178,6 +179,7 @@ "./packages/session-persistence/*/src", "./packages/session-projection/*/src", "./packages/session-query/*/src", + "./packages/session-registry/*/src", "./packages/session-title/*/src", "./packages/telemetry/*/src", "./packages/acp/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index 2287d21a9a..71157bca2a 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -86,6 +86,7 @@ { "path": "./packages/goal/goal-session" }, { "path": "./packages/goal/command-goal" }, { "path": "./packages/context/time-context" }, + { "path": "./packages/context/tmux-context" }, { "path": "./packages/context/session-reference" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, @@ -165,6 +166,7 @@ { "path": "./packages/todo/tool-todo" }, { "path": "./packages/plan/plan-mode" }, { "path": "./packages/guard/repeat-tool-guard" }, + { "path": "./packages/guard/source-guard" }, { "path": "./packages/cordis/tool-cordis" }, { "path": "./packages/hooks/hook-protocol" }, { "path": "./packages/hooks/hooks-claude" }, @@ -182,6 +184,9 @@ { "path": "./packages/lsp/lsp" }, { "path": "./packages/lsp/lsp-local" }, { "path": "./packages/lsp/tool-lsp" }, + { "path": "./packages/session-registry/session-registry" }, + { "path": "./packages/session-registry/session-registry-file" }, + { "path": "./packages/session-registry/session-registry-live" }, { "path": "./apps/cli" } ] } From f290a8b8513f82757f0348ac58a655e770ff94ff Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 13:58:21 +0800 Subject: [PATCH 03/77] refactor(cli)!: one shared base config with per-surface overlays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a twenty-key pass-through Config. Neither file was what its location claimed — apps/cli hardcoded the "example" as the product default and the "demo" bundle was the application — and every capability change had to be made twice. - apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and web.cordis.yml are patch lists stating only what differs per surface - overlays apply as SIBLING patch lists at one include level, because include patches never cross an include boundary. Precedence: base < surface < (--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches - `--config` now applies an overlay INSTEAD OF the personal one, so a demo or test tree never inherits the user's route; new `--config-replace` boots a file as the entire tree (the old `--config` behaviour). Both survive /resume - vendor/include: index each `insert`ed row as it is added so a later patch can configure or disable it. Upstream built the id index once before the patch loop, leaving every surface-only row — the whole TUI front door — silently unpatchable from user config. Logged as local modification 8 - session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY; dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it) - delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo; TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests, examples/code-mode survives as an overlay leaf - `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay Three latent defects surfaced and are fixed here: the TUI captured the optional sessionQuery service once at construction and could permanently disable /resume when it won the mount race; the session-store root silently reverted to a project-local ./.sessions; --config-replace was dropped by the resume handoff. Verified by booting each tree through the real Loader (TUI 55 entries, web 75, zero unsettled) rather than reading YAML. All eight terminal snapshots replay byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene and lint clean. --- ...7-23-client-plugin-loading-model.i18n.yaml | 6 +- .../2026-07-23-client-plugin-loading-model.md | 2 +- ...26-07-23-client-plugin-loading-model.zh.md | 2 +- ...tree-boot-and-transport-layering.i18n.yaml | 6 +- ...config-tree-boot-and-transport-layering.md | 2 +- ...fig-tree-boot-and-transport-layering.zh.md | 2 +- ...-native-typescript-source-launch.i18n.yaml | 4 +- ...-28-dsh-native-typescript-source-launch.md | 2 +- ...-dsh-native-typescript-source-launch.zh.md | 2 +- ...8-launcher-owned-resume-identity.i18n.yaml | 4 +- ...26-07-28-launcher-owned-resume-identity.md | 20 +- ...07-28-launcher-owned-resume-identity.zh.md | 20 +- ...-self-referential-cordis-toolset.i18n.yaml | 4 +- ...6-07-08-self-referential-cordis-toolset.md | 2 +- ...7-08-self-referential-cordis-toolset.zh.md | 2 +- ...cated-full-screen-tui-front-door.i18n.yaml | 4 +- ...17-dedicated-full-screen-tui-front-door.md | 2 +- ...dedicated-full-screen-tui-front-door.zh.md | 2 +- ...07-24-web-session-model-selector.i18n.yaml | 4 +- .../2026-07-24-web-session-model-selector.md | 2 +- ...026-07-24-web-session-model-selector.zh.md | 2 +- ...026-07-28-cross-workspace-resume.i18n.yaml | 4 +- .../2026-07-28-cross-workspace-resume.md | 6 +- .../2026-07-28-cross-workspace-resume.zh.md | 6 +- ...sh-guided-skill-session-commands.i18n.yaml | 4 +- ...07-28-dsh-guided-skill-session-commands.md | 4 +- ...28-dsh-guided-skill-session-commands.zh.md | 4 +- ...-20-remove-stdio-and-echo-agents.i18n.yaml | 6 +- ...2026-07-20-remove-stdio-and-echo-agents.md | 2 +- ...6-07-20-remove-stdio-and-echo-agents.zh.md | 2 +- ...7-29-shared-base-config-overlays.i18n.yaml | 6 + .../2026-07-29-shared-base-config-overlays.md | 53 +++ ...26-07-29-shared-base-config-overlays.zh.md | 53 +++ ...-18-tui-terminal-state-snapshots.i18n.yaml | 4 +- ...2026-07-18-tui-terminal-state-snapshots.md | 2 +- ...6-07-18-tui-terminal-state-snapshots.zh.md | 2 +- ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 +- .../2026-07-24-web-gui-browser-e2e-lane.md | 4 +- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 4 +- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/base.cordis.yml | 210 +++++++++ .../tui-agent => apps/cli}/composition.md | 145 +++--- apps/cli/cordis.yml | 420 ------------------ apps/cli/package.json | 3 +- apps/cli/src/app-cli-entry.ts | 62 ++- apps/cli/src/args.ts | 32 +- apps/cli/src/bin.ts | 4 +- apps/cli/src/headless.ts | 3 +- apps/cli/src/tui.ts | 62 ++- apps/cli/src/web.ts | 12 +- .../cli}/tests/fixtures/tui-scripted-llm.ts | 0 .../tests/fixtures/tui-scripted.cordis.yml | 71 +++ .../cli}/tests/pty-harness.ts | 0 .../bash-terminal-card/session.jsonl | 0 .../bash-terminal-card/terminal.expected.txt | 0 .../code-mode-dispatch-spill/session.jsonl | 0 .../terminal.expected.txt | 0 .../tests/snapshots/code-mode/session.jsonl | 0 .../snapshots/code-mode/terminal.expected.txt | 0 .../cordis-dynamic-toolchain/session.1.jsonl | 0 .../cordis-dynamic-toolchain/session.2.jsonl | 0 .../cordis-dynamic-toolchain/session.jsonl | 0 .../terminal.expected.txt | 0 .../dynamic-workflow/session.1.jsonl | 0 .../snapshots/dynamic-workflow/session.jsonl | 0 .../dynamic-workflow/terminal.expected.txt | 0 .../multi-turn-conversation/session.jsonl | 0 .../terminal.expected.txt | 0 .../parallel-file-reads/session.jsonl | 0 .../parallel-file-reads/terminal.expected.txt | 0 .../parallel-file-reads/workspace/a.txt | 0 .../parallel-file-reads/workspace/b.txt | 0 .../tests/snapshots/todo-plan/session.jsonl | 0 .../snapshots/todo-plan/terminal.expected.txt | 0 .../cli}/tests/tui-keyless-smoke.e2e.ts | 81 ++-- .../cli}/tests/tui.snapshot.ts | 0 apps/cli/tui.cordis.yml | 131 ++++++ apps/cli/web.cordis.yml | 167 +++++++ docs/capability-seams.md | 4 +- docs/config-catalog.md | 55 +-- docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 2 +- docs/cookbook/extension-cookbook.zh.md | 2 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 2 +- .../cordis-tutorial/01-first-plugin.i18n.yaml | 6 +- docs/cordis-tutorial/01-first-plugin.md | 2 +- docs/cordis-tutorial/01-first-plugin.zh.md | 2 +- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- docs/event-producer-consumer.md | 2 +- docs/graph-atlas.md | 3 +- docs/module-graph.md | 20 - docs/testing.i18n.yaml | 4 +- docs/testing.md | 2 +- docs/testing.zh.md | 2 +- docs/tool-catalog.md | 8 +- .../develop/practice/llm-adapter.i18n.yaml | 6 +- docs/user/develop/practice/llm-adapter.md | 8 +- docs/user/develop/practice/llm-adapter.zh.md | 8 +- docs/user/guide/config.i18n.yaml | 6 +- docs/user/guide/config.md | 13 +- docs/user/guide/config.zh.md | 13 +- docs/user/guide/index.i18n.yaml | 6 +- docs/user/guide/index.md | 15 +- docs/user/guide/index.zh.md | 15 +- docs/user/guide/quickstart.i18n.yaml | 6 +- docs/user/guide/quickstart.md | 2 +- docs/user/guide/quickstart.zh.md | 2 +- examples/README.i18n.yaml | 5 + examples/README.md | 12 +- examples/README.zh.md | 16 +- .../{tui-agent => code-mode}/README.i18n.yaml | 6 + examples/code-mode/README.md | 35 ++ examples/code-mode/README.zh.md | 35 ++ examples/code-mode/cordis.yml | 36 ++ examples/code-mode/package.json | 7 + examples/cordis-agent/README.md | 37 -- examples/cordis-agent/composition.md | 55 --- examples/cordis-agent/cordis.yml | 87 ---- examples/cordis-agent/package.json | 7 - .../cordis-agent/tests/cordis-tools.e2e.ts | 163 ------- examples/cordis-agent/tests/harness.ts | 41 -- .../cordis-agent/tests/keyless-smoke.e2e.ts | 23 - examples/package.json | 31 +- examples/tui-agent/README.md | 80 ---- examples/tui-agent/code-mode.cordis.yml | 30 -- examples/tui-agent/cordis.yml | 201 --------- examples/tui-agent/package.json | 7 - .../tests/fixtures/tui-scripted.cordis.yml | 53 --- examples/web-cordis/cordis.yml | 29 +- knip.json | 19 +- packages/client/AGENTS.md | 2 +- packages/core/agent-loop/src/index.ts | 57 +++ packages/examples/README.i18n.yaml | 5 + packages/examples/README.md | 3 +- packages/examples/README.zh.md | 8 + .../agent-spine-demo/README.i18n.yaml | 5 + packages/examples/agent-spine-demo/README.md | 2 +- .../examples/agent-spine-demo/README.zh.md | 4 + packages/examples/tui-demo/README.md | 112 ----- packages/examples/tui-demo/package.json | 76 ---- packages/examples/tui-demo/src/index.ts | 169 ------- packages/examples/tui-demo/src/invariant.ts | 30 -- .../examples/tui-demo/tests/tui-agent.spec.ts | 203 --------- packages/examples/tui-demo/tsconfig.json | 66 --- packages/examples/tui-demo/tsdown.config.ts | 19 - packages/guard/source-guard/README.i18n.yaml | 4 +- packages/guard/source-guard/README.md | 2 +- packages/guard/source-guard/README.zh.md | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/todo/README.i18n.yaml | 5 + packages/todo/README.md | 2 +- packages/todo/README.zh.md | 4 + packages/todo/tool-todo/README.i18n.yaml | 5 + packages/todo/tool-todo/README.md | 2 +- packages/todo/tool-todo/README.zh.md | 4 + packages/ui/README.i18n.yaml | 5 + packages/ui/README.md | 2 +- packages/ui/README.zh.md | 4 + packages/ui/app-boot/src/index.ts | 46 +- .../ui/app-boot/tests/config-reload.spec.ts | 49 ++ packages/ui/tui/src/chat/resume.ts | 23 +- packages/ui/tui/src/index.ts | 5 +- pnpm-lock.yaml | 134 +++--- scripts/demo-code-mode.mjs | 2 +- scripts/demo-cordis.mjs | 14 +- scripts/gen-doc-graphs.ts | 30 +- scripts/gen-tool-catalog.ts | 4 +- scripts/run-gates.ts | 2 +- .../request-response.expected.json | 4 +- scripts/verify-cordis-config.ts | 2 +- tsconfig.base.json | 1 + tsconfig.host.json | 1 - vendor/README.md | 2 +- vendor/include/src/index.ts | 6 + vitest.snapshot.config.ts | 2 + 182 files changed, 1659 insertions(+), 2400 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md create mode 100644 .agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md create mode 100644 apps/cli/base.cordis.yml rename {examples/tui-agent => apps/cli}/composition.md (51%) delete mode 100644 apps/cli/cordis.yml rename {examples/tui-agent => apps/cli}/tests/fixtures/tui-scripted-llm.ts (100%) create mode 100644 apps/cli/tests/fixtures/tui-scripted.cordis.yml rename {examples/tui-agent => apps/cli}/tests/pty-harness.ts (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/bash-terminal-card/session.jsonl (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/bash-terminal-card/terminal.expected.txt (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/code-mode-dispatch-spill/session.jsonl (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/code-mode/session.jsonl (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/code-mode/terminal.expected.txt (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/cordis-dynamic-toolchain/session.jsonl (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/dynamic-workflow/session.1.jsonl (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/dynamic-workflow/session.jsonl (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/dynamic-workflow/terminal.expected.txt (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/multi-turn-conversation/session.jsonl (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/multi-turn-conversation/terminal.expected.txt (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/parallel-file-reads/session.jsonl (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/parallel-file-reads/terminal.expected.txt (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/parallel-file-reads/workspace/a.txt (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/parallel-file-reads/workspace/b.txt (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/todo-plan/session.jsonl (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/todo-plan/terminal.expected.txt (100%) rename {examples/tui-agent => apps/cli}/tests/tui-keyless-smoke.e2e.ts (89%) rename {examples/tui-agent => apps/cli}/tests/tui.snapshot.ts (100%) create mode 100644 apps/cli/tui.cordis.yml create mode 100644 apps/cli/web.cordis.yml rename examples/{tui-agent => code-mode}/README.i18n.yaml (54%) create mode 100644 examples/code-mode/README.md create mode 100644 examples/code-mode/README.zh.md create mode 100644 examples/code-mode/cordis.yml create mode 100644 examples/code-mode/package.json delete mode 100644 examples/cordis-agent/README.md delete mode 100644 examples/cordis-agent/composition.md delete mode 100644 examples/cordis-agent/cordis.yml delete mode 100644 examples/cordis-agent/package.json delete mode 100644 examples/cordis-agent/tests/cordis-tools.e2e.ts delete mode 100644 examples/cordis-agent/tests/harness.ts delete mode 100644 examples/cordis-agent/tests/keyless-smoke.e2e.ts delete mode 100644 examples/tui-agent/README.md delete mode 100644 examples/tui-agent/code-mode.cordis.yml delete mode 100644 examples/tui-agent/cordis.yml delete mode 100644 examples/tui-agent/package.json delete mode 100644 examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml delete mode 100644 packages/examples/tui-demo/README.md delete mode 100644 packages/examples/tui-demo/package.json delete mode 100644 packages/examples/tui-demo/src/index.ts delete mode 100644 packages/examples/tui-demo/src/invariant.ts delete mode 100644 packages/examples/tui-demo/tests/tui-agent.spec.ts delete mode 100644 packages/examples/tui-demo/tsconfig.json delete mode 100644 packages/examples/tui-demo/tsdown.config.ts diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index 6c1dd7368d..bab12b6785 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-23-client-plugin-loading-model.md: 4028f50bf5cf8a05063df3bf5e2b4b7f45a3c02e -2026-07-23-client-plugin-loading-model.zh.md: 7dc96b47c950af07adb71ba7fe131d53e5c0e51d +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +2026-07-23-client-plugin-loading-model.md: ef9f14f2a150b0845ff89939bce5f8863dc15707 +2026-07-23-client-plugin-loading-model.zh.md: 0fbcbb0717be9d5aac27c33d9482c7e13bf7f4ea diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index 4028f50bf5..ef9f14f2a1 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -116,7 +116,7 @@ One governance implementation runs on both sides of the wire; the browser-specif Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows are informational — activation truth is service-level — so a mismatch surfaces at the settled sweep, not at graph validation; and the three not-yet-promoted libraries keep their static-import export surface until their DI conversions land. -Roster endgame (landed 2026-07-25 with the config-tree boot move): the roster lives in `apps/cli/cordis.yml`, `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer moved from a webserver-side registry into the `dsh-client-modules` node half (the package upgraded to dual-face per this note's promotion rule — its consumer now reaches it through cordis DI), and the transport split landed alongside: the webserver became a plain route-registration plugin, `/api/*` binding moved to the connection node half over the upgraded `api-gateway` plugin (`dsh-host-apiproxy` providing `ctx.apiProxy`), and the dev bundle watch + SSE channel moved to the hmr node half. +Roster endgame (landed 2026-07-25 with the config-tree boot move): the roster lives in `apps/cli/web.cordis.yml`, `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer moved from a webserver-side registry into the `dsh-client-modules` node half (the package upgraded to dual-face per this note's promotion rule — its consumer now reaches it through cordis DI), and the transport split landed alongside: the webserver became a plain route-registration plugin, `/api/*` binding moved to the connection node half over the upgraded `api-gateway` plugin (`dsh-host-apiproxy` providing `ctx.apiProxy`), and the dev bundle watch + SSE channel moved to the hmr node half. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index 7dc96b47c9..0fbcbb0717 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -116,7 +116,7 @@ wire 两侧跑着同一份治理实现;浏览器特有的表面只是一套模 接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;图中 `inject` 行仅是信息性说明——激活的真相在服务层——因此不匹配会在 settled 扫描时浮出,而不是在图校验时被拦下;三个尚未升格的库在各自的 DI 转换落地之前保持静态 import 的导出面。 -名册的终局(2026-07-25 随配置树 boot 迁移落地):名册住 `apps/cli/cordis.yml`,`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于换 yml/overlay。图的组合器从 webserver 侧的注册表迁进 `dsh-client-modules` 的 node 半(该包按本 note 的升级法则升格为双面——其消费方现经 cordis DI 到达),传输拆分同轮落地:webserver 变为朴素路由注册插件,`/api/*` 绑定迁到 connection 的 node 半、走升格后的 `api-gateway` 插件(`dsh-host-apiproxy` 提供 `ctx.apiProxy`),dev 的 bundle 监视与 SSE 通道迁到 hmr 的 node 半。 +名册的终局(2026-07-25 随配置树 boot 迁移落地):名册住 `apps/cli/base.cordis.yml` plus its surface overlay,`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于换 yml/overlay。图的组合器从 webserver 侧的注册表迁进 `dsh-client-modules` 的 node 半(该包按本 note 的升级法则升格为双面——其消费方现经 cordis DI 到达),传输拆分同轮落地:webserver 变为朴素路由注册插件,`/api/*` 绑定迁到 connection 的 node 半、走升格后的 `api-gateway` 插件(`dsh-host-apiproxy` 提供 `ctx.apiProxy`),dev 的 bundle 监视与 SSE 通道迁到 hmr 的 node 半。 ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index b8d0f27e13..1d723d4d94 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-24-web-config-tree-boot-and-transport-layering.md: 99e5d0d95f320464a6f12857e9d6e90e3587c777 -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 89c1a578f0ad0841eea7512538e3de8095b82c4d +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +2026-07-24-web-config-tree-boot-and-transport-layering.md: bf1dd829af73e61f2545c1f9034bc7ffda7042ed +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 03c1b64f3a9a17cb0f607764ea2c1a52b77551a3 diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index 99e5d0d95f..bf1dd829af 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -12,7 +12,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) ## Decision -**Composition is one flat config tree.** `apps/cli/cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. `--dev` appends the `dsh-client-hmr` row in code before the settle sweep — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven, and the boot compensates with a fail-loud triple: `assertEntriesLoaded` (import failures), `installFailLoud` (late apply rejections), and an all-ACTIVE sweep (PENDING fibers — cordis inject waiting has no timeout). +**Composition is one flat config tree.** `apps/cli/base.cordis.yml` plus its surface overlay holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle sweep — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven, and the boot compensates with a fail-loud triple: `assertEntriesLoaded` (import failures), `installFailLoud` (late apply rejections), and an all-ACTIVE sweep (PENDING fibers — cordis inject waiting has no timeout). **Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the triple. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index 89c1a578f0..03c1b64f3a 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -**组合是一棵平铺 config tree。** `apps/cli/cordis.yml` 持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。`--dev` 在 settle sweep 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动,boot 以 fail-loud 三件套补偿:`assertEntriesLoaded`(import 失败)、`installFailLoud`(迟到的 apply 拒绝)、all-ACTIVE sweep(PENDING fiber——cordis inject 等待没有超时)。 +**组合是一棵平铺 config tree。** `apps/cli/base.cordis.yml` plus its surface overlay 持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle sweep 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动,boot 以 fail-loud 三件套补偿:`assertEntriesLoaded`(import 失败)、`installFailLoud`(迟到的 apply 拒绝)、all-ACTIVE sweep(PENDING fiber——cordis inject 等待没有超时)。 **boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加三件套。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml index f3a3226c11..fae9bf86b2 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md -2026-07-28-dsh-native-typescript-source-launch.md: 1ba1dd2663038ad7c49af71f8b428245f7fa3e2b -2026-07-28-dsh-native-typescript-source-launch.zh.md: 02f84f34820469ad9e810ae17d79d3fe12b0cd4c +2026-07-28-dsh-native-typescript-source-launch.md: 3fa6ee3711ab5a44391e5c261cff1e7c05fa495a +2026-07-28-dsh-native-typescript-source-launch.zh.md: b749687b3a8c0f0fb92826dabbcedffa21e433f8 diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md index 1ba1dd2663..e128ae1a39 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md @@ -20,7 +20,7 @@ The `dsh` TUI, Web, and headless source launches use `node --experimental-transf `scripts/tspath-loader.ts` registers only a module resolve hook. It uses `TSX_TSCONFIG_PATH` when set (resolving relative values from the invoking cwd) and otherwise reads the root `tsconfig.json`; `TsconfigPathsResolver` follows that config's `extends` chain through the repository's existing TypeScript development tool, selects exact or wildcard `paths` entries according to tsconfig rules, and maps matching workspace bare specifiers to `.ts`/`.mts`/`.cts` source files or directory index files. Node remains solely responsible for code transformation. The source-only loader is not part of the built CLI and `apps/cli` does not declare `typescript` as a runtime dependency. -Source imports are redirected only when the target package is either the nearest package manifest's own name or one of that manifest's declared runtime dependencies. The Cordis Loader uses the configuration directory URL as the import parent; the resolver then searches upward for the workspace manifest that declares the plugin, so dependency ownership for `examples/tui-agent/cordis.yml` lies with `examples/package.json`, and dependency ownership for `apps/cli/cordis.yml` lies with `apps/cli/package.json`. Specifiers that do not match tsconfig paths, refer to undeclared dependencies, or are not bare all fall back to Node's default resolution. +Source imports are redirected only when the target package is either the nearest package manifest's own name or one of that manifest's declared runtime dependencies. The Cordis Loader uses the configuration directory URL as the import parent; the resolver then searches upward for the workspace manifest that declares the plugin, so dependency ownership for `examples/tui-agent/cordis.yml` lies with `examples/package.json`, and dependency ownership for `apps/cli/base.cordis.yml` plus its surface overlay lies with `apps/cli/package.json`. Specifiers that do not match tsconfig paths, refer to undeclared dependencies, or are not bare all fall back to Node's default resolution. `verify-cordis-config` performs a one-way completeness check on both resolver manifests: every bare plugin package in a configuration must appear in the corresponding manifest's `dependencies`, while the manifest may contain extra dependencies not referenced by that configuration. The root `AGENTS.md` makes updating the configuration and dependencies together a standing rule. diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md index 02f84f3482..5ff1d975f8 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md @@ -20,7 +20,7 @@ Cordis 配置还引入了另一条解析边界。`cordis.yml` 中的 bare plugin `scripts/tspath-loader.ts` 只注册一个模块 resolve hook。设置 `TSX_TSCONFIG_PATH` 时,它会使用该路径(相对路径从调用方的 cwd 解析),否则读取根 `tsconfig.json`;`TsconfigPathsResolver` 使用仓库已有的 TypeScript 开发工具沿该配置的 `extends` 链解析,按 tsconfig 规则选择精确或 wildcard `paths` 条目,并将命中的 workspace bare specifier 映射到 `.ts`/`.mts`/`.cts` 源文件或目录 index 文件。代码转换始终只由 Node 负责。该源码专用 loader 不属于构建后的 CLI,`apps/cli` 也不会把 `typescript` 声明为运行时依赖。 -只有当目标包是最近 package manifest 的自身名称或其已声明的运行时依赖时,源码 import 才会重定向。Cordis Loader 使用配置目录 URL 作为 import parent;此时 resolver 会向上查找声明该插件的 workspace manifest。因此,`examples/tui-agent/cordis.yml` 的依赖由 `examples/package.json` 持有,`apps/cli/cordis.yml` 的依赖由 `apps/cli/package.json` 持有。未命中 tsconfig paths、引用未声明依赖或不是 bare specifier 的说明符全部交回 Node 默认解析。 +只有当目标包是最近 package manifest 的自身名称或其已声明的运行时依赖时,源码 import 才会重定向。Cordis Loader 使用配置目录 URL 作为 import parent;此时 resolver 会向上查找声明该插件的 workspace manifest。因此,`examples/tui-agent/cordis.yml` 的依赖由 `examples/package.json` 持有,`apps/cli/base.cordis.yml` plus its surface overlay 的依赖由 `apps/cli/package.json` 持有。未命中 tsconfig paths、引用未声明依赖或不是 bare specifier 的说明符全部交回 Node 默认解析。 `verify-cordis-config` 对这两个解析方 manifest 执行单向完整性检查:配置中的每个 bare plugin package 都必须出现在对应 manifest 的 `dependencies` 中,manifest 可以包含该配置未引用的额外依赖。根 `AGENTS.md` 将同步更新配置和依赖定为常驻规则。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml index b63d7d3093..294898f6dd 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md -2026-07-28-launcher-owned-resume-identity.md: 8c48194892f67c1a0f3f87094cd174ca1a71a383 -2026-07-28-launcher-owned-resume-identity.zh.md: 88113017986ac8aaf473d246f701c977804353bf +2026-07-28-launcher-owned-resume-identity.md: 775f57c019a0dafbb2cfb724a8fb140610234032 +2026-07-28-launcher-owned-resume-identity.zh.md: 94e1de1402586186f6b184baeb8873b1e2868551 diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md index 8c48194892..775f57c019 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md +++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md @@ -6,7 +6,7 @@ English | [中文](2026-07-28-launcher-owned-resume-identity.zh.md) ## Problem -Two facts a launcher owns were shipped as deployment config keys on `dsh-tui-demo`: `resumeSessionId` (which session `main` binds to) and `resumeCommand` (the exit hint template, with `{session}` interpolated). Neither varies by deployment — both are properties of how the process was invoked, which only the launcher knows. +Two facts a launcher owns were shipped as deployment config keys on the TUI app bundle: `resumeSessionId` (which session `main` binds to) and `resumeCommand` (the exit hint template, with `{session}` interpolated). Neither varies by deployment — both are properties of how the process was invoked, which only the launcher knows. Routing them through YAML made them silently droppable. `@cordisjs/plugin-include` applies a targeted patch by replacing whole top-level keys (`target[key] = value`), so a personal `~/.dsh/config.yaml` patching the `tui-agent` entry's `config` replaces the shipped block entirely. A user overlay written to change provider and model therefore deleted every resume key it did not restate, and nothing reported it: absent `resumeCommand` legitimately means "no fallback configured". @@ -16,12 +16,14 @@ A config key cannot express these facts safely, because the deployment is not th ## Decision -Session identity and the exit line are launcher-owned context slots, provided before any Loader entry mounts. Neither appears in any `cordis.yml` or in `dsh-tui`'s or `dsh-tui-demo`'s `Config`. +Session identity and the exit line are launcher-owned context slots, provided before any Loader entry mounts. Neither appears in any `cordis.yml` nor in any plugin's `Config`. -`dsh-tui` declares both slots beside the existing `tuiResumeHost` host capability, which set the precedent — a resume host has always been a provided capability rather than config: +Both sit beside the existing `tuiResumeHost` host capability, which set the precedent — a resume host has always been a provided capability rather than config. Each slot is declared by the package that consumes it: -- `MAIN_SESSION_ID_KEY` carries a `MainSessionIdentity` (`{ id: SessionId, resume: boolean }`). `dsh-tui-demo` binds both the TUI and the configured agent to `id`, and takes the history-loading `resumeSessionId` path only when `resume` is set, because that path requires an existing log and fails loud without one. An absent slot means no launcher chose a session, so the app mints `main-session-` and creates it fresh. -- `TUI_GOODBYE_MESSAGE_KEY` carries the complete line printed once the terminal is released on exit. Absent prints nothing. +- `CONFIGURED_AGENT_IDENTITIES_KEY` (`dsh-agent-loop`) carries launcher identities keyed by configured-agent `id`, each a `LauncherAgentIdentity` (`{ id: SessionId, resume: boolean }`). `agent-loop` applies the matching identity over its configured agent, replacing both identity keys, and takes the history-loading `resumeSessionId` path only when `resume` is set, because that path requires an existing log and fails loud without one. An absent slot leaves the configured identity untouched. The `tui` row resolves the same id through its own `sessionId` key, so the front door renders exactly the agent that was bound. +- `TUI_GOODBYE_MESSAGE_KEY` (`dsh-tui`) carries the complete line printed once the terminal is released on exit. Absent prints nothing. + +Identity belongs to `agent-loop` because that is the plugin which creates configured agents, and because a patch replaces a row's whole `config`: an overlay repointing the agent row's model route would erase a launcher-set identity key. See [the shared-base overlay note](../simplification/2026-07-29-shared-base-config-overlays.md). `apps/cli` mints or selects the id and builds the line from the invocation it is reproducing, sharing one `resumeArgs` helper with the `/resume` execve handoff so the printed command and the in-place handoff cannot diverge. The line now names `--config` when one was passed, and reproduces `dsh meta --resume ` in meta mode — closing the mode-aware hint deferred by the `dsh meta` note, where a copied hint previously only worked from the checkout. @@ -33,9 +35,9 @@ The TUI owns rendering, not wording: it applies `displayText` before its own `pa ## Alternatives considered -**Keep the keys and add built-in defaults in `dsh-tui-demo`.** Rejected: a default in code survives an overlay, but two ways to state one fact remain, and a config author can still set the key wrong — which is exactly how the stale `process.env.RESUME_SESSION_ID` line disabled resume. +**Keep the keys and add built-in defaults in the app bundle.** Rejected: a default in code survives an overlay, but two ways to state one fact remain, and a config author can still set the key wrong — which is exactly how the stale `process.env.RESUME_SESSION_ID` line disabled resume. -**Merge `dsh-tui-demo` into `apps/cli` and delete the slot entirely.** Rejected after investigation, though it is the only way to remove the slot. `examples/tui-agent/code-mode.cordis.yml` patches the `tui-agent` entry through a nested `plugin-include` to switch `tools.mode` and the persona, and `examples/cordis-agent/cordis.yml` reuses the bundle as a different product; both extension points exist only because `tui-agent` is a declared config entry. Merging also moves a 162-line, 18-dependency composition into the CLI's `v8 ignore` process-wiring block, out of the per-file coverage gate. +**Merge the app bundle into `apps/cli` and delete the slot entirely.** Rejected here, then [adopted later](../simplification/2026-07-29-shared-base-config-overlays.md) in a form this note did not consider: the composition moved into flat config files (`apps/cli/base.cordis.yml` plus a per-surface overlay) rather than into CLI code, so it never entered the `v8 ignore` process-wiring block, and the overlay extension points survive as ordinary row patches. The slot itself was not deleted — it moved to `dsh-agent-loop`, because a launcher fact still cannot travel through a replaceable config key. **Put the goodbye message on `TuiResumeHost`.** Rejected: an exit line is not a handoff capability, and a host that cannot replace its process may still want to print one. They are independent slots. @@ -55,8 +57,8 @@ The TUI owns rendering, not wording: it applies `displayText` before its own `pa ## Testing -`packages/ui/tui/tests/tui.spec.ts` pins the printed line, the absent-slot silence, and escape sanitization of a hostile message; the former two exit-suppression tests are replaced, since suppression is the behavior this change removes. `packages/examples/tui-demo/tests/tui-agent.spec.ts` drives the identity slot for the resume, launcher-minted, and no-slot cases through a fake `ctx.get`. +`packages/ui/tui/tests/tui.spec.ts` pins the printed line, the absent-slot silence, and escape sanitization of a hostile message; the former two exit-suppression tests are replaced, since suppression is the behavior this change removes. `packages/core/agent-loop/tests/` drives the identity slot for the resume, launcher-minted, and no-slot cases. -The load-bearing coverage is `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts`, which launches the real `apps/cli/src/bin.ts` in a PTY: one test asserts the exit line carries `--config`, and a regression test seeds a personal `config.yaml` that replaces the entire `tui-agent` config block and asserts the line still prints — encoding "an overlay cannot drop resume" as an executed contract rather than a comment. +The load-bearing coverage is `apps/cli/tests/tui-keyless-smoke.e2e.ts`, which launches the real `apps/cli/src/bin.ts` in a PTY: one test asserts the exit line carries `--config`, and a regression test seeds a personal `config.yaml` that replaces the entire `agent-loop` config block and asserts the line still prints — encoding "an overlay cannot drop resume" as an executed contract rather than a comment. Verified live in tmux against the real personal overlay: the defect reproduced on unmodified staging (requested id ignored, fresh id in the banner), and on this branch the same overlay yields a printed exit line, a `--resume` that restores the prior turn, and a `/resume` selector marking the session `current · live · persisted`. A wrong id now fails loud. diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md index 8811301798..94e1de1402 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -有两项本应由启动器持有的事实,却被作为 `dsh-tui-demo` 上的部署配置键交付:`resumeSessionId`(`main` 绑定到哪个会话)与 `resumeCommand`(退出提示的模板,其中 `{session}` 会被插值)。二者都不随部署而变——它们都是进程被如何调用的属性,而这一点只有启动器知道。 +有两项本应由启动器持有的事实,却被作为 TUI 应用组合包上的部署配置键交付:`resumeSessionId`(`main` 绑定到哪个会话)与 `resumeCommand`(退出提示的模板,其中 `{session}` 会被插值)。二者都不随部署而变——它们都是进程被如何调用的属性,而这一点只有启动器知道。 把它们经由 YAML 传递,使其可被静默丢弃。`@cordisjs/plugin-include` 施加定向补丁的方式是替换整个顶层键(`target[key] = value`),因此一份对 `tui-agent` 条目的 `config` 打补丁的个人 `~/.dsh/config.yaml`,会把交付时的整块内容整体替换掉。于是,一份为改动 provider 和 model 而写的用户 overlay,会删掉它未重述的每一个 resume 键,且没有任何东西报告这一点:缺失 `resumeCommand` 合法地意味着「未配置回退」。 @@ -16,12 +16,14 @@ Status: implemented ## Decision -会话身份与退出行是由启动器持有的上下文槽位,在任何 Loader 条目挂载之前提供。二者都不出现在任何 `cordis.yml` 中,也不出现在 `dsh-tui` 或 `dsh-tui-demo` 的 `Config` 中。 +会话身份与退出行是由启动器持有的上下文槽位,在任何 Loader 条目挂载之前提供。二者都不出现在任何 `cordis.yml` 中,也不出现在任何插件的 `Config` 中。 -`dsh-tui` 在既有的 `tuiResumeHost` 宿主能力旁声明这两个槽位,后者确立了先例——resume 宿主一直是一项被提供的能力,而非配置: +这两个槽位与既有的 `tuiResumeHost` 宿主能力并列,后者确立了先例——resume 宿主一直是一项被提供的能力,而非配置。每个槽位都由消费它的包声明: -- `MAIN_SESSION_ID_KEY` 承载一个 `MainSessionIdentity`(`{ id: SessionId, resume: boolean }`)。`dsh-tui-demo` 把 TUI 与所配置的 agent 都绑定到 `id`,并且仅当 `resume` 被置位时才走加载历史的 `resumeSessionId` 路径,因为该路径要求存在一份日志、否则会明确报错。槽位缺失意味着没有启动器选定会话,于是应用铸造 `main-session-` 并新建它。 -- `TUI_GOODBYE_MESSAGE_KEY` 承载退出时终端释放后打印一次的完整行。缺失则什么都不打印。 +- `CONFIGURED_AGENT_IDENTITIES_KEY`(`dsh-agent-loop`)按所配置 agent 的 `id` 承载启动器身份,每项为一个 `LauncherAgentIdentity`(`{ id: SessionId, resume: boolean }`)。`agent-loop` 将匹配的身份覆盖到其所配置的 agent 上,替换两个身份键;并且仅当 `resume` 被置位时才走加载历史的 `resumeSessionId` 路径,因为该路径要求存在一份日志、否则会明确报错。槽位缺失则保留配置中的身份不变。`tui` 配置项通过自身的 `sessionId` 键解析同一个 id,因此前端入口渲染的正是被绑定的那个 agent。 +- `TUI_GOODBYE_MESSAGE_KEY`(`dsh-tui`)承载退出时终端释放后打印一次的完整行。缺失则什么都不打印。 + +身份归属于 `agent-loop`,因为它才是创建所配置 agent 的插件;也因为 patch 会整体替换配置项的 `config`:重新指向 agent 配置项模型路由的 overlay 会抹掉启动器设置的身份键。参见[共享 base overlay note](../simplification/2026-07-29-shared-base-config-overlays.md)。 `apps/cli` 铸造或选定 id,并依据它所复现的那次调用构建该行,与 `/resume` 的 execve 移交共用同一个 `resumeArgs` 助手,从而使打印出的命令与原地移交不会分歧。该行现在会在传入了 `--config` 时命名它,并在 meta 模式下复现 `dsh meta --resume `——从而收口了 `dsh meta` note 所推迟的随 mode 变化的提示,在那里被复制的提示此前只有在检出目录中才有效。 @@ -33,9 +35,9 @@ TUI 持有渲染,而非措辞:它在自己的 `palette.muted` 之前先应 ## Alternatives considered -**保留这些键,并在 `dsh-tui-demo` 中加入内建默认值。** 拒绝:代码中的默认值能在 overlay 下存活,但表达同一事实的两种途径依然并存,而配置作者仍可把键设错——这正是那行陈旧的 `process.env.RESUME_SESSION_ID` 使 resume 失效的方式。 +**保留这些键,并在应用组合包中加入内建默认值。** 拒绝:代码中的默认值能在 overlay 下存活,但表达同一事实的两种途径依然并存,而配置作者仍可把键设错——这正是那行陈旧的 `process.env.RESUME_SESSION_ID` 使 resume 失效的方式。 -**把 `dsh-tui-demo` 合并进 `apps/cli` 并彻底删除该槽位。** 经调查后拒绝,尽管这是移除该槽位的唯一途径。`examples/tui-agent/code-mode.cordis.yml` 通过一个嵌套的 `plugin-include` 给 `tui-agent` 条目打补丁,以切换 `tools.mode` 与人设,而 `examples/cordis-agent/cordis.yml` 把该 bundle 作为另一款产品复用;这两个扩展点都仅因 `tui-agent` 是一个声明式配置条目才存在。合并还会把一段 162 行、18 个依赖的组合逻辑挪进 CLI 的 `v8 ignore` 进程接线块中,脱离逐文件覆盖率门禁。 +**把应用组合包合并进 `apps/cli` 并彻底删除该槽位。** 此处拒绝,但后来以本 note 未曾设想的形式[被采纳](../simplification/2026-07-29-shared-base-config-overlays.md):组合被搬进平铺的配置文件(`apps/cli/base.cordis.yml` 加各 surface 一份 overlay),而非搬进 CLI 代码,因此从未进入 `v8 ignore` 进程接线块,overlay 的扩展点也作为普通配置项 patch 保留了下来。槽位本身并未被删除——它迁移到了 `dsh-agent-loop`,因为启动器的事实依然不能经由一个可被整体替换的配置键传递。 **把 goodbye 消息放到 `TuiResumeHost` 上。** 拒绝:退出行不是一项移交能力,而一个无法替换自身进程的宿主仍可能想要打印一行。它们是相互独立的槽位。 @@ -55,8 +57,8 @@ TUI 持有渲染,而非措辞:它在自己的 `palette.muted` 之前先应 ## Testing -`packages/ui/tui/tests/tui.spec.ts` 钉住打印出的行、槽位缺失时的静默,以及对恶意消息的转义净化;此前那两个退出抑制测试被替换,因为抑制正是本次改动移除的行为。`packages/examples/tui-demo/tests/tui-agent.spec.ts` 通过一个伪造的 `ctx.get`,为 resume、启动器铸造与无槽位三种情形驱动身份槽位。 +`packages/ui/tui/tests/tui.spec.ts` 钉住打印出的行、槽位缺失时的静默,以及对恶意消息的转义净化;此前那两个退出抑制测试被替换,因为抑制正是本次改动移除的行为。`packages/core/agent-loop/tests/` 为 resume、启动器铸造与无槽位三种情形驱动身份槽位。 -承重的覆盖是 `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts`,它在一个 PTY 中拉起真实的 `apps/cli/src/bin.ts`:一个测试断言退出行携带 `--config`,一个回归测试植入一份个人 `config.yaml` 来替换整块 `tui-agent` 配置块并断言该行仍会打印——把「overlay 不能丢掉 resume」编码为一条被执行的契约,而非一句注释。 +承重的覆盖是 `apps/cli/tests/tui-keyless-smoke.e2e.ts`,它在一个 PTY 中拉起真实的 `apps/cli/src/bin.ts`:一个测试断言退出行携带 `--config`,一个回归测试植入一份个人 `config.yaml` 来替换整块 `agent-loop` 配置块并断言该行仍会打印——把「overlay 不能丢掉 resume」编码为一条被执行的契约,而非一句注释。 在 tmux 中针对真实的个人 overlay 做过实测:该缺陷在未修改的 staging 上复现(所请求的 id 被忽略,banner 里是新的 id),而在本分支上同一份 overlay 会产出一行打印的退出行、一个能恢复上一轮次的 `--resume`,以及一个把该会话标记为 `current · live · persisted` 的 `/resume` 选择器。错误的 id 现在会明确报错。 diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml index 421933e57a..56c5d930b4 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md -2026-07-08-self-referential-cordis-toolset.md: 40934fe0e2975c4e068df6ef8f31ed7921df3230 -2026-07-08-self-referential-cordis-toolset.zh.md: 13662b9359aa85895ce85391ebd5a5902cc451cc +2026-07-08-self-referential-cordis-toolset.md: 335d5e808016ebc37c8457c5dcf4d7da9d8b8c93 +2026-07-08-self-referential-cordis-toolset.zh.md: 8d2a105a2189aa23915f718c6440069d1a2aa9ed diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md index 40934fe0e2..335d5e8080 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md @@ -12,7 +12,7 @@ First, model-written registration must be validated where it happens: a malforme ## Decision -The toolset ships as [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) and is demoed by [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md). It gives the model three tools over the live Cordis runtime in the current DSH process: inspect it, mount an in-memory temporary Plugin, and unmount that Plugin to quiescence. +The toolset ships as [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) and is demoed by `examples/web-cordis`. It gives the model three tools over the live Cordis runtime in the current DSH process: inspect it, mount an in-memory temporary Plugin, and unmount that Plugin to quiescence. The vm isolates accidental global pollution, and the context façade hides framework internals. Neither restricts the authority of exposed services: a temporary Plugin can call `ctx.bash` with the host executor's privileges and reach the real filesystem and web services. It runs in the shared DSH runtime and may affect other sessions in that process. This is an opt-in development tool with bash-equivalent trust, not a security boundary or product default. diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md index 13662b9359..8d2a105a21 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -该工具集以 [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) 发布,并由 [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md) 演示。它为模型提供三个工具,操作当前 DSH 进程中的活跃 Cordis 运行时:审视它、挂载一个仅存于内存的临时 Plugin,再将该 Plugin 卸载至完全停稳。 +该工具集以 [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) 发布,并由 `examples/web-cordis` 演示。它为模型提供三个工具,操作当前 DSH 进程中的活跃 Cordis 运行时:审视它、挂载一个仅存于内存的临时 Plugin,再将该 Plugin 卸载至完全停稳。 vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节。但二者都不限制已暴露服务的权限:临时 Plugin 可以调用 `ctx.bash` 以宿主执行器的权限运行命令,也能访问真实的文件系统和网络服务。它运行在共享 DSH runtime 中,可能影响同一进程的其他 session。这是一个需要显式启用的开发工具,信任等级与 bash 相当,不是安全边界,也不是产品默认配置。 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml index bba253325c..330dacea22 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md -2026-07-17-dedicated-full-screen-tui-front-door.md: bc6241e925d5bf094deded761fb96fd6b6c48a1f -2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 0f5306ff547cb975b29d1fbcb7e610b3a540ce18 +2026-07-17-dedicated-full-screen-tui-front-door.md: 0f9492509a4fedcf1fcd16deed3b9cd570e3ba18 +2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 0d5a508d200ee39d751070854ce9b93f78ad4121 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md index bc6241e925..0f9492509a 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md @@ -14,7 +14,7 @@ The interactive channel must remain a Cordis plugin over the same agent, session DeepSeek Harness ships [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) as a dedicated Cordis plugin. It owns terminal input and presentation only; agent lifecycle, session persistence, tool execution, and the model-facing question tool remain separate composition entries. The plugin requires both stdin and stdout to be TTYs and fails instead of silently changing to line-oriented behavior. -The app layer has one terminal front door. `@deepseek-ai/dsh-tui-demo` mounts the TUI before the configured agent, and `examples/tui-agent` owns the interactive coding composition and Code Mode overlay directly. Non-interactive tasks use `@deepseek-ai/dsh-cli-demo`; ACP remains a separate automation protocol. +There is one terminal front door. `@deepseek-ai/dsh-tui` mounts before the configured agent, and `apps/cli/tui.cordis.yml` — an overlay over the shared `base.cordis.yml` — owns the interactive coding composition, with the Code Mode overlay in `examples/code-mode`. Non-interactive tasks use `@deepseek-ai/dsh-cli-demo`; ACP remains a separate automation protocol. The selected front door receives the exact generated or resumed `SessionId` used by the pre-created agent. It mounts before the agent composition, waits for the matching root agent, and enters full-screen mode only after that agent exists. A matching `agent-loop/config-start-failed` event is therefore reported before screen takeover and exits with status 1. diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md index 0f5306ff54..0d5a508d20 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md @@ -14,7 +14,7 @@ Status: implemented DeepSeek Harness 将 [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 作为独立的 Cordis 插件交付。该插件只负责终端输入与呈现;agent 生命周期、会话持久化、工具执行以及模型可见的提问工具仍由不同组合项负责。插件要求 stdin 和 stdout 均为 TTY;条件不满足时会失败,不会静默切换为逐行输出。 -应用组合层只有一个终端入口。`@deepseek-ai/dsh-tui-demo` 在已配置 agent 之前挂载 TUI,`examples/tui-agent` 直接拥有交互式 coding 组装及其 Code Mode overlay。非交互任务使用 `@deepseek-ai/dsh-cli-demo`;ACP 仍是独立的自动化协议。 +只有一个终端入口。`@deepseek-ai/dsh-tui` 在已配置 agent 之前挂载,而 `apps/cli/tui.cordis.yml`——叠加在共享 `base.cordis.yml` 之上的 overlay——拥有交互式 coding 组装,Code Mode overlay 则位于 `examples/code-mode`。非交互任务使用 `@deepseek-ai/dsh-cli-demo`;ACP 仍是独立的自动化协议。 所选入口接收预创建 agent 使用的同一个新建或恢复 `SessionId`。入口先于 agent 组合挂载,等待相符的根 agent 出现,然后才进入全屏模式。因此,相符的 `agent-loop/config-start-failed` 事件会在接管屏幕前报告,并以状态码 1 退出。 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml index b65218274c..55b9d222b4 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md -2026-07-24-web-session-model-selector.md: 4bd52bba8d3ba16c01fc59d2b561516f6fd0bb87 -2026-07-24-web-session-model-selector.zh.md: 35f3525b80e7d27fb573e854aefc317ed354df53 +2026-07-24-web-session-model-selector.md: bf2f1f3c677ef576b1a00fb8bd0133154462d705 +2026-07-24-web-session-model-selector.zh.md: b2e17bdf487c717dc85e9b16b8f8e526557cdfb9 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md index 4bd52bba8d..bf2f1f3c67 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md @@ -18,7 +18,7 @@ The browser `ModelService` owns one `ModelDirectory` per live session. Its snaps `@deepseek-ai/dsh-client-ui-conversation` declares the session-scoped single slot `conversation.input.model` as a child of its composer-bar entry. InputBar renders the seat in its trailing controls immediately before the pending indicator and primary button; the seat receives the bar's `locked` owner prop and session scope. `@deepseek-ai/dsh-client-ui-model` occupies that seat and also contributes `/model` over the same directory. Its compact trigger displays the catalog model name and effective reasoning label, falling back to ids when metadata is absent. The upward menu first offers Model and, when the current exact model supports it, Effort; Model drills into provider groups, while Effort drills into the adapter-ordered levels. The provider-default row appears only when the adapter does not configure a model default. -The production browser roster is the flat config tree in `apps/cli/cordis.yml`; the model feature is one `dshClient` row rather than a package hardcoded in Web boot code. Its package manifest orders it after the runtime and command feature, while Cordis service injection waits for the conversation slot before registering the composer occupant. +The production browser roster is the flat config tree in `apps/cli/web.cordis.yml`; the model feature is one `dshClient` row rather than a package hardcoded in Web boot code. Its package manifest orders it after the runtime and command feature, while Cordis service injection waits for the conversation slot before registering the composer occupant. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md index 35f3525b80..b2e17bdf48 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md @@ -18,7 +18,7 @@ Web Host 为每个新建或恢复的 agent(智能体)复用 `installAgentLlm `@deepseek-ai/dsh-client-ui-conversation` 将会话作用域的单实例 slot `conversation.input.model` 声明为其输入栏 entry 的子 slot。InputBar 在尾部控件区将该 seat 渲染于 pending 指示器与主按钮之前;该 seat 接收输入栏的 `locked` owner prop 与会话作用域。`@deepseek-ai/dsh-client-ui-model` 占用该 seat,并在同一目录上提供 `/model`。其紧凑型触发器显示目录中的模型名称与生效的推理强度标签;元数据缺失时则回退到相应 ID。向上展开的菜单首先提供 Model,并在当前精确模型支持时提供 Effort;Model 可深入提供方分组,Effort 可深入适配器排序的级别。仅当适配器没有配置模型默认值时,才显示提供方默认值行。 -生产环境的浏览器名册是 `apps/cli/cordis.yml` 中的平铺 config tree;模型功能对应其中一行 `dshClient` 配置项,而不是 Web boot 代码中硬编码的包。其包 manifest(元数据清单)将加载顺序置于运行时与命令功能之后;Cordis 服务注入则等待 conversation slot 可用,再注册 composer 占用方。 +生产环境的浏览器名册是 `apps/cli/base.cordis.yml` plus its surface overlay 中的平铺 config tree;模型功能对应其中一行 `dshClient` 配置项,而不是 Web boot 代码中硬编码的包。其包 manifest(元数据清单)将加载顺序置于运行时与命令功能之后;Cordis 服务注入则等待 conversation slot 可用,再注册 composer 占用方。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml index 60285a26b0..848b39e600 100644 --- a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md -2026-07-28-cross-workspace-resume.md: 09b638398ea9379d39df94fcb42da3395cdd70df -2026-07-28-cross-workspace-resume.zh.md: 5a2e7d2535c07b4ace0416b234dc28b32cbcd2fc +2026-07-28-cross-workspace-resume.md: d559b73a5ba0f8136d20ef6dcf7c62989d1527e9 +2026-07-28-cross-workspace-resume.zh.md: 404b81cbc07a455e5553a9c227d663e491456c9e diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md index 09b638398e..d559b73a5b 100644 --- a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md @@ -8,7 +8,7 @@ English | [中文](2026-07-28-cross-workspace-resume.zh.md) `/resume` could only reach sessions started in the launch directory, so returning to yesterday's work in another project meant remembering its path, leaving the TUI, and relaunching there. Two independent causes produced that limit, and fixing either alone changes nothing. -Storage was the binding one. The shipped `tui-demo` bundle defaulted `persistenceRoot` to a relative `./.sessions`, so each launch directory owned a disjoint JSONL root and a disjoint derived `session-query.db`. Sessions from another project were not filtered out of the listing — they were absent from the store the listing reads. The JSONL backend already partitions per-cwd *inside* one root, so the partitioning was doubled: once by root, once within it. +Storage was the binding one. The shipped TUI composition defaulted its persistence root to a relative `./.sessions`, so each launch directory owned a disjoint JSONL root and a disjoint derived `session-query.db`. Sessions from another project were not filtered out of the listing — they were absent from the store the listing reads. The JSONL backend already partitions per-cwd *inside* one root, so the partitioning was doubled: once by root, once within it. The picker then filtered again. It dropped records whose `cwd` differed from the current session before display, and `summarizeResumeCandidate` independently marked a differing `cwd` as `disabledReason: 'different workspace'`, so a foreign session that did reach the store was both hidden and refused. @@ -20,7 +20,7 @@ The dsh launcher supplies one session root under its Harness home through a boot **Storage.** `dsh-paths` owns the location as `resolveSessionsRoot()` (`sessions` under the Harness home, by `resolveDshHome`'s precedence), but only the launcher assumes it: shared-store policy is the dsh CLI's, never a plugin's. The TUI surface provides the root through the `SESSIONS_ROOT_KEY` boot slot (`ctx.provide` before Loader entries mount) and `dsh web` patches the same root in `apps/cli/src/app-cli-entry.ts`. Two CLI surfaces computing that path independently is exactly the failure this change fixes — disjoint stores — so the fact gets one home rather than a `join` per caller, alongside the existing `registryRoot()` precedent for `run`. -`tui-demo` itself keeps a project-local `./.sessions` default and reads the launcher slot between explicit config and that default (`config.persistenceRoot ?? ctx.get(SESSIONS_ROOT_KEY) ?? './.sessions'`). The precedence lives in `composeTuiApp`, not as a schemastery `.default()`, because a schema default would materialize before the compose function runs and shadow the slot for every Loader mount. `examples/tui-agent/cordis.yml` omits `persistenceRoot` so the launcher slot (or, for a bare example boot, the project-local default) applies. Configuring an explicit root always wins, which remains the correct choice for a hermetic deployment. +The shared base states that precedence in the row itself: `apps/cli/base.cordis.yml`'s `session-persistence-jsonl` row reads `root: !!js launcherSessionsRoot ?? './.sessions'`, so the launcher slot wins and a bare boot without one keeps the project-local default. Expressing it as the row's own `!!js` value rather than a schemastery `.default()` matters for the same reason it did in the bundle: a schema default would materialize before the slot could be read. An overlay or personal patch that states an explicit root always wins, which remains the correct choice for a hermetic deployment. **Scope, not exclusion.** A workspace other than the current one is a display scope rather than a disabled reason. `showResume()` summarizes every record and the `ResumePicker` owns a `scope` of `'workspace' | 'all'`, defaulting to the current workspace so the common case is unchanged. Tab toggles; the scope line names the active scope and the count the other holds; each row in the all-workspaces scope reports its own workspace, and that label joins the searchable text only in the scope that shows it. A toggle clears the query and selection so the highlighted row always belongs to the visible list, and the per-row workspace line makes a row one terminal row taller in that scope, which the visible-count budget accounts for. @@ -49,4 +49,4 @@ The dsh launcher supplies one session root under its Harness home through a boot ## Testing -TUI tests cover the default scope hiding other workspaces while reporting their count, Tab revealing them with per-row workspace labels, Tab back clearing the query and selection, searching by workspace label, a cwd-less record staying visible but disabled, and the handoff receiving both the id and the workspace re-read at preflight. The former "reject a moved cwd" case now asserts the handoff carries the new directory. `dsh-paths` tests pin `resolveSessionsRoot`'s precedence against `resolveDshHome`'s. `tui-demo` composition tests pin the project-local default and the derived `session-query.db` path. The keyless TUI snapshot pins both scopes of the selector, including the scope line, the per-row workspace lines, and the Tab hint in the footer. A manual cross-workspace resume verified at the process level that the replacement's working directory became the target workspace. +TUI tests cover the default scope hiding other workspaces while reporting their count, Tab revealing them with per-row workspace labels, Tab back clearing the query and selection, searching by workspace label, a cwd-less record staying visible but disabled, and the handoff receiving both the id and the workspace re-read at preflight. The former "reject a moved cwd" case now asserts the handoff carries the new directory. `dsh-paths` tests pin `resolveSessionsRoot`'s precedence against `resolveDshHome`'s. `apps/cli` tests pin the project-local default and the derived `session-query.db` path. The keyless TUI snapshot pins both scopes of the selector, including the scope line, the per-row workspace lines, and the Tab hint in the footer. A manual cross-workspace resume verified at the process level that the replacement's working directory became the target workspace. diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md index 5a2e7d2535..404b81cbc0 100644 --- a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md @@ -8,7 +8,7 @@ Status: implemented `/resume` 只能触达在启动目录中创建的会话,因此要回到昨天在另一个项目里的工作,就得记住它的路径、退出 TUI、再到那里重新启动。造成这一限制的原因有两个,彼此独立,只修其中一个都不会有任何变化。 -存储是那个决定性的原因。已交付的 `tui-demo` 组合包把 `persistenceRoot` 默认成相对路径 `./.sessions`,于是每个启动目录都独占一份互不相交的 JSONL 根目录,以及一份互不相交的派生 `session-query.db`。来自另一个项目的会话并不是在列表中被过滤掉的——它们根本不存在于列表读取的存储中。JSONL 后端本来就会在*同一个*根目录*内部*按 cwd 分区,所以分区被叠加了两层:一层按根目录,一层在根目录内部。 +存储是那个决定性的原因。已交付的 TUI 组合把持久化根默认成相对路径 `./.sessions`,于是每个启动目录都独占一份互不相交的 JSONL 根目录,以及一份互不相交的派生 `session-query.db`。来自另一个项目的会话并不是在列表中被过滤掉的——它们根本不存在于列表读取的存储中。JSONL 后端本来就会在*同一个*根目录*内部*按 cwd 分区,所以分区被叠加了两层:一层按根目录,一层在根目录内部。 接着选择器又过滤了一次。它在展示前丢弃 `cwd` 与当前会话不同的记录,而 `summarizeResumeCandidate` 又独立地把不同的 `cwd` 标记为 `disabledReason: 'different workspace'`,于是一个确实进入了存储的外部会话既被隐藏,也会被拒绝。 @@ -20,7 +20,7 @@ dsh 启动器通过启动槽位提供其 Harness home 下的同一个会话根 **存储。** `dsh-paths` 以 `resolveSessionsRoot()` 拥有该位置(按 `resolveDshHome` 的优先级,取 Harness home 下的 `sessions`),但只有启动器假定它:共享存储策略属于 dsh CLI,绝不属于插件。TUI 界面通过 `SESSIONS_ROOT_KEY` 启动槽位(在 Loader 条目挂载前 `ctx.provide`)提供该根目录,`dsh web` 则在 `apps/cli/src/app-cli-entry.ts` 中为同一根目录打补丁。CLI 的两处界面各自独立计算该路径,正是本次改动所修复的那种失败——互不相交的存储——因此这项事实只有一个归属,而不是每个调用方各做一次 `join`,这与 `run` 已有的 `registryRoot()` 先例一致。 -`tui-demo` 自身保持项目本地的 `./.sessions` 默认值,并在显式配置与该默认值之间读取启动器槽位(`config.persistenceRoot ?? ctx.get(SESSIONS_ROOT_KEY) ?? './.sessions'`)。这一优先级放在 `composeTuiApp` 内,而不是写成 schemastery 的 `.default()`,因为 schema 默认值会在 compose 函数运行前物化,使每次 Loader 挂载都遮蔽该槽位。`examples/tui-agent/cordis.yml` 不写 `persistenceRoot`,因此启动器槽位(裸示例启动时则为项目本地默认值)生效。显式配置的根目录总是获胜,对于封闭部署来说这仍然是正确的选择。 +共享 base 直接在配置项自身表达这一优先级:`apps/cli/base.cordis.yml` 的 `session-persistence-jsonl` 配置项写作 `root: !!js launcherSessionsRoot ?? './.sessions'`,因此启动器槽位优先,而没有槽位的裸启动则保留项目本地默认值。把它写成配置项自身的 `!!js` 取值、而不是 schemastery 的 `.default()`,其原因与在组合包中相同:schema 默认值会在能够读取槽位之前就物化。若 overlay 或个人 patch 显式声明了根目录,则始终以其为准——对于要求自洽封闭的部署,这仍是正确选择。 **是范围,不是排除。** 当前 workspace 之外的 workspace 是一种展示范围,而不是禁用理由。`showResume()` 汇总每一条记录,`ResumePicker` 持有一个 `'workspace' | 'all'` 的 `scope`,默认为当前 workspace,因此常见场景毫无变化。Tab 切换范围;范围行会说明当前生效的范围,以及另一个范围下的数量;在全 workspace 范围中每一行都报告自己的 workspace,而该标签只在展示它的范围里才加入可搜索文本。切换范围会清空查询和选中项,使高亮行始终属于可见列表;而逐行的 workspace 行会让该范围下的每一行在终端里多占一行,可见条数预算已经把这一点计入。 @@ -49,4 +49,4 @@ dsh 启动器通过启动槽位提供其 Harness home 下的同一个会话根 ## Testing -TUI 测试覆盖默认范围隐藏其他 workspace 但报告其数量、Tab 显示它们并带上逐行 workspace 标签、再按 Tab 返回时清空查询与选中项、按 workspace 标签搜索、无 cwd 的记录仍可见但不可选,以及交接同时收到 id 和在预检时重新读取到的 workspace。原先「拒绝已移动的 cwd」的用例现在断言交接携带新目录。`dsh-paths` 测试固定 `resolveSessionsRoot` 的优先级与 `resolveDshHome` 的一致。`tui-demo` 组合测试固定项目本地默认值以及派生出的 `session-query.db` 路径。无密钥 TUI 快照固定选择器的两个范围,包括范围行、逐行 workspace 行,以及页脚中的 Tab 提示。手动执行的一次跨 workspace 恢复在进程层面验证了替换后进程的工作目录变为目标 workspace。 +TUI 测试覆盖默认范围隐藏其他 workspace 但报告其数量、Tab 显示它们并带上逐行 workspace 标签、再按 Tab 返回时清空查询与选中项、按 workspace 标签搜索、无 cwd 的记录仍可见但不可选,以及交接同时收到 id 和在预检时重新读取到的 workspace。原先「拒绝已移动的 cwd」的用例现在断言交接携带新目录。`dsh-paths` 测试固定 `resolveSessionsRoot` 的优先级与 `resolveDshHome` 的一致。`apps/cli` 测试钉住项目本地默认值与派生的 `session-query.db` 路径。无密钥 TUI 快照固定选择器的两个范围,包括范围行、逐行 workspace 行,以及页脚中的 Tab 提示。手动执行的一次跨 workspace 恢复在进程层面验证了替换后进程的工作目录变为目标 workspace。 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml index 7e8ca82fae..c60a7f0127 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md -2026-07-28-dsh-guided-skill-session-commands.md: 338629f5a1adb9c1973daf87f2f52479bd70ba47 -2026-07-28-dsh-guided-skill-session-commands.zh.md: a9a8a70212dd92b9c850a529789f4e4879838090 +2026-07-28-dsh-guided-skill-session-commands.md: a11807dca9ad1640857cd95a8b528df691b8a27e +2026-07-28-dsh-guided-skill-session-commands.zh.md: e48420ed77496d8c336031473364f9008fccced7 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md index 338629f5a1..a11807dca9 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md @@ -12,7 +12,7 @@ Two recurring flows begin with the user manually invoking one skill and answerin `dsh migrate` and `dsh upgrade` boot the ordinary TUI as a fresh session whose first turn auto-invokes a bundled skill (`dsh-migrate`, `dsh-upgrade`), exactly as if the user typed `/skill:` and pressed Enter. -The seed reuses the existing TUI skill path, not a new one. `createTuiChat` already has `invokeSkill(name, instructions)` — the code a typed `/skill:` runs, including the "Unknown skill" notice. The launcher passes the skill name to the app through a new boot-context slot `INITIAL_SKILL_KEY` (`tuiInitialSkill`), mirroring `MAIN_SESSION_ID_KEY`/`TUI_GOODBYE_MESSAGE_KEY`: `ctx.provide` is the only channel from launcher argv into a Loader-mounted plugin. The TUI's `apply()` reads the slot and folds it into `config.initialSkill`; after `ui.start()` succeeds, `createTuiChat` fires `invokeSkill(config.initialSkill, '')` once when set. +The seed reuses the existing TUI skill path, not a new one. `createTuiChat` already has `invokeSkill(name, instructions)` — the code a typed `/skill:` runs, including the "Unknown skill" notice. The launcher passes the skill name to the TUI through a new boot-context slot `INITIAL_SKILL_KEY` (`tuiInitialSkill`), mirroring `CONFIGURED_AGENT_IDENTITIES_KEY`/`TUI_GOODBYE_MESSAGE_KEY`: `ctx.provide` is the only channel from launcher argv into a Loader-mounted plugin. The TUI's `apply()` reads the slot and folds it into `config.initialSkill`; after `ui.start()` succeeds, `createTuiChat` fires `invokeSkill(config.initialSkill, '')` once when set. **Freshness is gated in the launcher, not the TUI.** `runSkillSession` always mints a fresh session and provides the slot only when `resumeSessionId === undefined`, so a later `dsh --resume ` of that session is an ordinary TUI session with no re-injection. The TUI stays generic: it invokes whatever skill it is handed, once, at startup. @@ -36,7 +36,7 @@ No keyless PTY snapshot: per the maintainer's scope call for this change, unit c **Support `--resume` on `migrate`/`upgrade`.** Rejected: these are one-shot guided entries. A resumed session is an ordinary TUI session reachable through the default surface's `dsh --resume `; re-injecting the skill on resume would duplicate the first turn. -**Read `INITIAL_SKILL_KEY` in the app bundle (like `MAIN_SESSION_ID_KEY`) rather than in the TUI's `apply()`.** Not needed: `initialSkill` is a TUI `Config` field consumed in `createTuiChat`, so folding the slot into config at the TUI entry keeps it beside the other launcher-owned runtime reads (`tuiResumeHost`, `tuiGoodbyeMessage`) and leaves the app bundle unchanged. +**Read `INITIAL_SKILL_KEY` outside the TUI (as `CONFIGURED_AGENT_IDENTITIES_KEY` is read by `agent-loop`) rather than in the TUI's `apply()`.** Not needed: `initialSkill` is a TUI `Config` field consumed in `createTuiChat`, so folding the slot into config at the TUI entry keeps it beside the other launcher-owned runtime reads (`tuiResumeHost`, `tuiGoodbyeMessage`) and touches no other plugin. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md index a9a8a70212..e48420ed77 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md @@ -12,7 +12,7 @@ Status: implemented `dsh migrate` 与 `dsh upgrade` 以全新会话启动普通 TUI,其首轮自动调用一个内置 skill(`dsh-migrate`、`dsh-upgrade`),效果等同于用户键入 `/skill:` 并回车。 -播种复用现有的 TUI skill 路径,而非新增一条。`createTuiChat` 已有 `invokeSkill(name, instructions)`——即键入 `/skill:` 所走的代码,包含“未知 skill”通知。启动器通过一个新的启动上下文槽 `INITIAL_SKILL_KEY`(`tuiInitialSkill`)把 skill 名称传给应用,与 `MAIN_SESSION_ID_KEY`/`TUI_GOODBYE_MESSAGE_KEY` 一致:`ctx.provide` 是从启动器 argv 进入 Loader 挂载插件的唯一通道。TUI 的 `apply()` 读取该槽并折叠进 `config.initialSkill`;`ui.start()` 成功后,`createTuiChat` 在其被设置时调用一次 `invokeSkill(config.initialSkill, '')`。 +播种复用现有的 TUI skill 路径,而非新增一条。`createTuiChat` 已有 `invokeSkill(name, instructions)`——即键入 `/skill:` 所走的代码,包含“未知 skill”通知。启动器通过一个新的启动上下文槽 `INITIAL_SKILL_KEY`(`tuiInitialSkill`)把 skill 名称传给 TUI,与 `CONFIGURED_AGENT_IDENTITIES_KEY`/`TUI_GOODBYE_MESSAGE_KEY` 一致:`ctx.provide` 是从启动器 argv 进入 Loader 挂载插件的唯一通道。TUI 的 `apply()` 读取该槽并折叠进 `config.initialSkill`;`ui.start()` 成功后,`createTuiChat` 在其被设置时调用一次 `invokeSkill(config.initialSkill, '')`。 **新鲜性在启动器而非 TUI 中把关。** `runSkillSession` 总是创建全新会话,且仅在 `resumeSessionId === undefined` 时提供该槽,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。TUI 保持通用:它只是把接到的 skill 在启动时调用一次。 @@ -36,7 +36,7 @@ Status: implemented **在 `migrate`/`upgrade` 上支持 `--resume`。** 已否决:它们是一次性引导入口。恢复的会话是可经默认界面 `dsh --resume ` 到达的普通 TUI 会话;恢复时重新注入 skill 会重复首轮。 -**在应用 bundle 中读取 `INITIAL_SKILL_KEY`(像 `MAIN_SESSION_ID_KEY` 那样)而非在 TUI 的 `apply()` 中。** 无此必要:`initialSkill` 是在 `createTuiChat` 中消费的 TUI `Config` 字段,因此在 TUI 入口处把该槽折叠进 config,可与其他启动器拥有的运行时读取(`tuiResumeHost`、`tuiGoodbyeMessage`)并列,且无需改动应用 bundle。 +**在 TUI 之外读取 `INITIAL_SKILL_KEY`(如同 `agent-loop` 读取 `CONFIGURED_AGENT_IDENTITIES_KEY` 那样),而非在 TUI 的 `apply()` 中。** 无此必要:`initialSkill` 是在 `createTuiChat` 中消费的 TUI `Config` 字段,因此在 TUI 入口处把该槽位折叠进配置,可以让它与其他由启动器持有的运行时读取(`tuiResumeHost`、`tuiGoodbyeMessage`)并列,且不触及任何其他插件。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml index 89b7165650..68cca7bc54 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-20-remove-stdio-and-echo-agents.md: d804f8e3c3de886fc48c87d6378c9f06d0e3c15a -2026-07-20-remove-stdio-and-echo-agents.zh.md: d42e6d527231e1703153c02c66d9286fa1e73c95 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md +2026-07-20-remove-stdio-and-echo-agents.md: 4b6a41489b3a1031aad6134052273035ea208e30 +2026-07-20-remove-stdio-and-echo-agents.zh.md: 95af2e74ccce4738fed713c3c58904c48517a479 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md index d804f8e3c3..4b6a41489b 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md @@ -18,7 +18,7 @@ The stdio and Echo agents are removed without compatibility packages, modes, com The remaining application roles are explicit: -- [`@deepseek-ai/dsh-tui-demo`](../../../../packages/examples/tui-demo/README.md) owns terminal-interactive execution. It rejects non-TTY streams before Loader boot; `examples/tui-agent` owns the complete coding composition, Code Mode overlay, PTY coverage, and terminal snapshots. +- [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) owns terminal-interactive execution. It rejects non-TTY streams before Loader boot; `apps/cli/base.cordis.yml` plus the `tui.cordis.yml` overlay own the complete coding composition, with the Code Mode overlay in `examples/code-mode` and PTY plus terminal-snapshot coverage in `apps/cli/tests/`. - [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) owns non-interactive execution, including pipes. `examples/headless-agent` owns the real-model one-shot composition, replay snapshots, generic real-agent suites, and test-only keyless Loader fixtures. - [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) and `@deepseek-ai/dsh-jsonrpc` own their framed protocol integrations. diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md index d42e6d5272..95af2e74cc 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md @@ -18,7 +18,7 @@ DeepSeek Harness 在 TUI 和 Headless coding agent 之外,还提供了两个 保留的应用角色均有明确归属: -- [`@deepseek-ai/dsh-tui-demo`](../../../../packages/examples/tui-demo/README.md) 负责终端交互式执行。它会在 Loader 启动前拒绝非 TTY 流;`examples/tui-agent` 拥有完整 coding 组装、Code Mode 覆盖层、PTY 覆盖和终端快照。 +- [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 负责终端交互式执行。它会在 Loader 启动前拒绝非 TTY 流;`examples/tui-agent` 拥有完整 coding 组装、Code Mode 覆盖层、PTY 覆盖和终端快照。 - [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) 负责非交互式执行,包括管道方式。`examples/headless-agent` 拥有真实模型的单次任务组装、回放快照、通用真实 agent 测试套件,以及仅供测试使用的无密钥 Loader fixture。 - [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) 和 `@deepseek-ai/dsh-jsonrpc` 负责各自的分帧协议集成。 diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml new file mode 100644 index 0000000000..ac8a924568 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md +2026-07-29-shared-base-config-overlays.md: c3c1bd962dd61201958053d1f1c04e05646f78bd +2026-07-29-shared-base-config-overlays.zh.md: dcf7eaafa7f46b8c2fc1a45e627d31770ba3b234 diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md new file mode 100644 index 0000000000..c3c1bd962d --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md @@ -0,0 +1,53 @@ +# Agent Note: One shared base config with per-surface overlays + +Status: implemented + +English | [中文](2026-07-29-shared-base-config-overlays.zh.md) + +## Problem + +`dsh` shipped two full config trees that were 43 rows the same. `apps/cli/cordis.yml` composed the web surface as 74 flat rows, while the TUI booted `examples/tui-agent/cordis.yml`, whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins and re-declared their configuration as its own twenty-key pass-through `Config`. + +Neither file was what its location claimed. `examples/tui-agent` was not an example: `apps/cli/src/tui.ts` hardcoded it as the product's default config, and it owned the TUI PTY smoke, the eight terminal snapshot scenarios, and the PTY harness the `cordis-agent` leaf imported. `dsh-tui-demo` was not a demo either — it was the application, mounted by the shipped binary from `packages/examples/`. + +The duplication was the load-bearing problem. Of the 43 shared rows, 38 were byte-identical and 5 differed for a defensible per-surface reason, so every capability change had to be made twice and could silently drift. The bundle also inverted a default: `composeTuiApp` read `config.goals ?? {}`, so the shipped TUI mounted goals, `tool-goal`, `goal-session`, and `/goal` although no config key requested them. + +## Decision + +One shared base, one overlay per surface, composed as sibling patch lists. + +`apps/cli/base.cordis.yml` holds the 43 rows both surfaces mount. `apps/cli/tui.cordis.yml` and `apps/cli/web.cordis.yml` are **patch lists**, not trees: each states the handful of rows whose value is surface-specific and inserts its own rows. The launcher includes the base once and applies every overlay as a sibling patch list at **one** include level, because include patches never cross an include boundary — stacking overlays as nested includes would silently stop reaching base rows. + +Precedence is list order, last write winning per row: base, then the surface overlay, then either a `--config` overlay or the personal `~/.dsh/config.yaml`, then the launcher's own flag and profile patches. + +`--config ` now applies an overlay **instead of** the personal overlay, so a demo or test tree never inherits the user's provider and model. `--config-replace ` boots a file as the entire tree, bypassing base, surface overlay, and personal overlay alike; that is what the old `--config` did, so trees like `examples/web-cordis` moved to the new flag. Both flags survive the `/resume` execve handoff, or resuming would silently change the agent. + +A patch replaces its target row's whole `config` rather than merging, which shapes the split: a row whose value differs per surface lives in the overlays, never in the base, so no row is patched by three layers at once. Session identity therefore cannot ride a config key at all — it moved to `dsh-agent-loop`'s `CONFIGURED_AGENT_IDENTITIES_KEY`, as [the launcher-owned identity note](../architecture/2026-07-28-launcher-owned-resume-identity.md) now records. + +`examples/tui-agent`, `examples/cordis-agent`, and `packages/examples/tui-demo` are deleted. The TUI tests move to `apps/cli/tests/`, the cordis-toolset e2e to `packages/cordis/tool-cordis/tests/`, and `examples/code-mode` survives as a genuine example: an overlay that patches `tools.mode` and inserts the code runtime. + +## Alternatives considered + +**Leave both trees flat and duplicated.** Rejected: 43 rows maintained twice is the defect, and a gate asserting they stay identical would freeze the duplication rather than remove it. + +**Nest the overlays as includes (`code-mode` → `tui` → `base`).** Rejected after testing the Loader: patches do not cross an include boundary, so the outer file's patches are dropped with only a warning. A three-level chain left `tools` unpatchable, and a base behind one include made every personal patch a silent no-op. + +**Put the union of all rows in the base and have each overlay disable what it does not want.** Rejected: the base stops meaning "shared", and each surface carries rows it exists only to switch off. + +**Keep the per-surface rows in the base and let overlays patch them.** Adopted only for the five rows that must exist in both trees, because a patch cannot create a row. Their base entries carry the plugin name and the config both surfaces share; each overlay states the rest. + +## Consequences + +An overlay or `--config` tree that named `@deepseek-ai/dsh-tui-demo`, or patched the `tui-agent` row, no longer resolves. Overlays now patch the row that owns each key: the model route on `agent-loop`, the persona on `system-prompt`, presentation on `tui`. + +A patch whose `id` matches no row stays a Loader warning rather than an error. That is deliberate: one personal overlay is shared across surfaces, and `insert` rows match nothing by design, so a row that exists only under `web` must not fail the TUI's boot. + +`dsh web` gains `--config`, threaded into `AppCLIEntry` as an extra overlay. `AppCLIEntry` reads both the base and its surface overlay when recovering row defaults for its own patch merge, since a flag override must preserve the overlay's other fields on the same row. + +## Verification + +Composition is checked by booting each tree through the real Loader and inspecting settled entries, not by reading YAML: the TUI settles 55 entries and web 75, both with zero unloaded or unsettled rows, and web's `httpServer` up. The three-layer case (`base` + `tui` + `code-mode`) confirms `tools.mode` reaching `code` over the TUI overlay's `native`. + +All eight terminal snapshot scenarios replay byte-identically after moving, and the 14-case PTY smoke passes, including two cases that assert a personal overlay reaches an **inserted** row — the behavior the vendored `plugin-include` fix enables ([`vendor/README.md`](../../../../vendor/README.md) local modification 8, covered by `packages/ui/app-boot/tests/config-reload.spec.ts`). + +Flattening surfaced three latent defects, each fixed here: the TUI captured the optional `sessionQuery` service once at construction and so could permanently disable `/resume` when it won the mount race; the shipped session-store root silently reverted to a project-local `./.sessions`; and `--config-replace` was dropped by the resume handoff. diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md new file mode 100644 index 0000000000..dcf7eaafa7 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md @@ -0,0 +1,53 @@ +# Agent Note: 一份共享 base 配置加各 surface 的 overlay + +Status: implemented + +[English](2026-07-29-shared-base-config-overlays.md) | 中文 + +## 问题 + +`dsh` 交付了两棵完整的配置树,其中 43 个配置项完全相同。`apps/cli/cordis.yml` 以 74 个平铺配置项组合 web surface,而 TUI 启动的是 `examples/tui-agent/cordis.yml`——其中单独一行 `@deepseek-ai/dsh-tui-demo` 挂载了十二个插件,并把它们的配置重新声明为自己那份二十个键、仅作透传的 `Config`。 + +这两份文件都名不副实。`examples/tui-agent` 并不是示例:`apps/cli/src/tui.ts` 把它硬编码为产品的默认配置;它还拥有 TUI 的 PTY 冒烟测试、八个终端快照场景,以及被 `cordis-agent` 叶节点 import 的 PTY harness。`dsh-tui-demo` 也不是 demo——它就是应用本身,由交付的二进制从 `packages/examples/` 中挂载。 + +真正决定性的问题是重复。43 个共享配置项中,38 个逐字节相同,5 个因各 surface 的正当理由而不同;因此每次能力改动都必须改两处,而且可能无声漂移。该组合包还反转了一个默认值:`composeTuiApp` 读取 `config.goals ?? {}`,于是交付的 TUI 挂载了 goals、`tool-goal`、`goal-session` 和 `/goal`——尽管没有任何配置键要求它们。 + +## 决策 + +一份共享 base,每个 surface 一份 overlay,以平级 patch 列表的形式组合。 + +`apps/cli/base.cordis.yml` 持有两个 surface 都会挂载的 43 个配置项。`apps/cli/tui.cordis.yml` 与 `apps/cli/web.cordis.yml` 是 **patch 列表**,不是配置树:各自声明少数取值因 surface 而异的配置项,并 insert 自己的配置项。启动器只 include base 一次,并把每个 overlay 作为**同一** include 层级上的平级 patch 列表应用——因为 include patch 不会跨越 include 边界,把 overlay 堆叠成嵌套 include 会使其静默地无法触达 base 配置项。 + +优先级即列表顺序,逐配置项后写者胜:base,然后是 surface overlay,接着是 `--config` overlay 或个人 `~/.dsh/config.yaml`,最后是启动器自身的 flag 与 profile patch。 + +`--config ` 现在应用一个 overlay 来**取代**个人 overlay,因此 demo 或测试用的树绝不会继承用户的 provider 与 model。`--config-replace ` 则把某个文件作为整棵树启动,同时绕过 base、surface overlay 与个人 overlay;这正是旧 `--config` 的行为,所以像 `examples/web-cordis` 这样的树改用了新 flag。两个 flag 都会在 `/resume` 的 execve 交接中保留,否则 resume 会静默更换 agent。 + +patch 会整体替换目标配置项的 `config` 而不合并,这决定了拆分方式:取值因 surface 而异的配置项住在 overlay 中,绝不住在 base 里,从而没有任何配置项会被三层同时 patch。因此会话身份根本不能经由配置键传递——它迁移到了 `dsh-agent-loop` 的 `CONFIGURED_AGENT_IDENTITIES_KEY`,如[启动器持有身份的 note](../architecture/2026-07-28-launcher-owned-resume-identity.md) 现在所记录。 + +`examples/tui-agent`、`examples/cordis-agent` 与 `packages/examples/tui-demo` 均被删除。TUI 测试迁往 `apps/cli/tests/`,cordis 工具集的 e2e 迁入 `packages/cordis/tool-cordis/tests/`,而 `examples/code-mode` 作为一个名副其实的示例保留下来:一个 patch `tools.mode` 并 insert code runtime 的 overlay。 + +## 备选方案 + +**保留两棵平铺且重复的树。** 拒绝:43 个配置项维护两份正是缺陷本身,而用一个门禁断言二者保持一致只会固化重复,而非消除它。 + +**把 overlay 嵌套成 include(`code-mode` → `tui` → `base`)。** 在对 Loader 实测后拒绝:patch 不会跨越 include 边界,因此外层文件的 patch 只会伴随一条告警被丢弃。三层链条使 `tools` 无法被 patch,而位于一层 include 之后的 base,会让每个个人 patch 都变成静默的空操作。 + +**把所有配置项的并集放进 base,由各 overlay 禁用自己不需要的部分。** 拒绝:base 将不再意味着「共享」,而每个 surface 都要携带仅为将其关闭而存在的配置项。 + +**把因 surface 而异的配置项留在 base 中,由 overlay 去 patch。** 仅对必须同时存在于两棵树中的那五个配置项采用,因为 patch 无法创建配置项。它们在 base 中的条目携带插件名与两个 surface 共享的配置,其余部分由各 overlay 声明。 + +## 影响 + +指名 `@deepseek-ai/dsh-tui-demo`、或 patch `tui-agent` 配置项的 overlay 与 `--config` 树将不再可解析。overlay 现在要 patch 拥有对应键的那一行:模型路由在 `agent-loop`,人设在 `system-prompt`,呈现设置在 `tui`。 + +若某个 patch 的 `id` 不匹配任何配置项,Loader 仍只告警而不报错。这是有意为之:同一份个人 overlay 会跨 surface 共用,而 `insert` 配置项按设计本就不匹配任何目标,因此仅在 `web` 下存在的配置项不能让 TUI 启动失败。 + +`dsh web` 新增 `--config`,作为一份额外 overlay 传入 `AppCLIEntry`。`AppCLIEntry` 在为自身 patch 合并恢复配置项默认值时会同时读取 base 与其 surface overlay,因为 flag 覆盖必须保留同一配置项上 overlay 的其他字段。 + +## 验证 + +组合的正确性通过用真实 Loader 启动每棵树并检查已就绪的条目来核对,而不是靠阅读 YAML:TUI 就绪 55 个条目、web 就绪 75 个,两者都没有未加载或未就绪的配置项,且 web 的 `httpServer` 已启动。三层叠加的情形(`base` + `tui` + `code-mode`)确认 `tools.mode` 越过 TUI overlay 的 `native` 达到了 `code`。 + +全部八个终端快照场景在迁移后逐字节重放一致,14 个用例的 PTY 冒烟测试全部通过,其中两个用例断言个人 overlay 能触达一个 **insert 进来的**配置项——这正是 vendored `plugin-include` 修复所启用的行为([`vendor/README.md`](../../../../vendor/README.md) 本地修改第 8 条,由 `packages/ui/app-boot/tests/config-reload.spec.ts` 覆盖)。 + +平铺过程暴露出三处潜伏缺陷,均在此一并修复:TUI 曾在构造时一次性捕获可选的 `sessionQuery` 服务,因此在挂载竞争中胜出时会永久禁用 `/resume`;交付的会话存储根目录曾静默退回项目本地的 `./.sessions`;`--config-replace` 曾在 resume 交接中被丢弃。 diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml index d491394db7..9345449920 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md -2026-07-18-tui-terminal-state-snapshots.md: 18c79bc2d0dabf4d78887354f30a2cdc083899e1 -2026-07-18-tui-terminal-state-snapshots.zh.md: d1d4a6ca859e94a153e0bf645a17f03c0dac234b +2026-07-18-tui-terminal-state-snapshots.md: b8e6f77d96fbd4d077d55fefa3652813c737a37b +2026-07-18-tui-terminal-state-snapshots.zh.md: 1b2f6b58e53d9ccd5f0935dab9e9812d2bfe3eb4 diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md index 18c79bc2d0..b8e6f77d96 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md @@ -21,7 +21,7 @@ TUI coverage has four complementary layers: 3. `examples/tui-agent/tests/tui.snapshot.ts` replays committed JSONL session logs through the production agent loop and real tools, then compares the resulting semantic terminal state. 4. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the real Loader composition in a PTY, drives a scripted conversation through streaming and `ask_user_question`, and verifies startup, input, exit, failure reporting, and terminal restoration. -The runnable TUI has its own `examples/tui-agent` leaf beside the Headless and ACP leaves. It owns the interactive coding backends and tools directly and loads `@deepseek-ai/dsh-tui-demo`; TUI snapshots and PTY tests live with that leaf. The [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) owns this consolidation. +The runnable TUI is the shipped `apps/cli` composition: the shared `base.cordis.yml` plus the `tui.cordis.yml` overlay, which owns the interactive coding backends, tools, and front door. TUI snapshots and PTY tests live in `apps/cli/tests/`. The [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) owns this consolidation. ### Recorded-session replay diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md index d1d4a6ca85..1b2f6b58e5 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md @@ -21,7 +21,7 @@ TUI 覆盖分为四个互补层次: 3. `examples/tui-agent/tests/tui.snapshot.ts` 通过生产 agent loop 和真实工具回放已提交的 JSONL 会话日志,再比较生成的语义终端状态。 4. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 在 PTY 中启动真实 Loader 组合,驱动一段经过流式输出和 `ask_user_question` 的脚本化会话,并验证启动、输入、退出、失败报告和终端恢复。 -可运行 TUI 在 `examples/tui-agent` 中拥有独立叶节点,与 Headless 和 ACP 叶节点并列。它直接拥有交互式 coding 后端与工具,并加载 `@deepseek-ai/dsh-tui-demo`;TUI 快照和 PTY 测试也归属这个叶节点。[移除重复 agent 的决策](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)负责此次整合。 +可运行 TUI 就是交付的 `apps/cli` 组合:共享的 `base.cordis.yml` 加 `tui.cordis.yml` overlay,后者拥有交互式 coding 后端、工具与前端入口。TUI 快照和 PTY 测试位于 `apps/cli/tests/`。[移除重复 agent 的决策](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)负责此次整合。 ### 已录制会话回放 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 0283559c9c..0863874748 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: ce59dcce270d548c91e3719eee8e9c83aea0c154 -2026-07-24-web-gui-browser-e2e-lane.zh.md: bad3dd15ed7b98cc17340666a6c1094d0de057b1 +2026-07-24-web-gui-browser-e2e-lane.md: 37e2bccddb3725073ebb38377c2cd7464b418012 +2026-07-24-web-gui-browser-e2e-lane.zh.md: f2bbdbb028a3e35831298fea4d5a0f3dec410b82 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index ce59dcce27..04240456d1 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -16,7 +16,7 @@ The web GUI ships as a real assembled chain — chromium page → client plugin A plain shared-fixture module (the [testing-policy sanctioned shape](../../../../docs/testing.md)), not a package: the gate-worthy logic — replay derivation, session parsing, log scrubbing, persistence — lives in the gated packages `dsh-llm-replay`, `dsh-acp-snapshot`, and `dsh-session-persistence-jsonl`; what remains is boot wiring and browser glue, and chromium-driving code cannot hold per-file 100% coverage on the browserless coverage runners. -`launchWebScaffold()` boots the real web composition from the shipped `apps/cli/cordis.yml` through the vendored Loader's include mechanism — the same tree and mechanism `AppCLIEntry` drives for `dsh web`. Divergences ride include patches over that tree, the ACP `cordis.snapshot.yml` pattern expressed in-process: temp `persistenceRoot`, `workspace-context` disabled (recorded fixtures must not embed this repo's AGENTS.md), `session-title-llm` disabled (its fire-and-forget title call would race the loop for the session's replay cursor), the webserver row pinned to port 0 with the built dist, and in keyless modes `llm-deepseek` disabled. A patch id that stops matching a row fails the boot sweep loudly instead of drifting. The boot runs `chdir`'d to the temp workspace so the api-gateway's `process.cwd()` session default, tool cwds, and fixtures agree; the `dsh web` bin's own glue (argv, profile json, AppCLIEntry) stays held by the keyless CLI smokes in `smoke-real.e2e.ts`. Setup rollback and ordinary close both dispose the Cordis tree before removing the two owned temp roots, attempt every cleanup independently, and report cleanup failures without masking the setup failure. +`launchWebScaffold()` boots the real web composition from the shipped `apps/cli/base.cordis.yml` plus its surface overlay through the vendored Loader's include mechanism — the same tree and mechanism `AppCLIEntry` drives for `dsh web`. Divergences ride include patches over that tree, the ACP `cordis.snapshot.yml` pattern expressed in-process: temp `persistenceRoot`, `workspace-context` disabled (recorded fixtures must not embed this repo's AGENTS.md), `session-title-llm` disabled (its fire-and-forget title call would race the loop for the session's replay cursor), the webserver row pinned to port 0 with the built dist, and in keyless modes `llm-deepseek` disabled. A patch id that stops matching a row fails the boot sweep loudly instead of drifting. The boot runs `chdir`'d to the temp workspace so the api-gateway's `process.cwd()` session default, tool cwds, and fixtures agree; the `dsh web` bin's own glue (argv, profile json, AppCLIEntry) stays held by the keyless CLI smokes in `smoke-real.e2e.ts`. Setup rollback and ordinary close both dispose the Cordis tree before removing the two owned temp roots, attempt every cleanup independently, and report cleanup failures without masking the setup failure. Keyless model displacement is the disabled adapter row plus `installLlmReplay` filling the open seam on the settled root ctx in providers-catalog mode — never catch-all: with the adapter row disabled no adapter exists, so catch-all would leave `resolveModelInfo` unroutable and `compact-basic`'s post-step pressure check would warn every step instead of being provably inert (the published 128k `contextWindow` keeps it inert for small fixtures). The direct install rather than an inserted replay plugin row is deliberate: it returns the `ReplayHandle` the teardown consumption check needs. A scenario with no fixture leaves the seam empty, so a stray stream fails loud with NO_ADAPTER. @@ -66,7 +66,7 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot **A committed normalized-session-log golden as a second expected surface.** Rejected: the log surface is pinned by the ACP/headless/TUI suites through the same loop and persistence; here it would double refresh cost and re-test lower tiers. Inline world-state assertions on root-context events keep the world-verification duty. -**Spawning the `dsh web` bin with a `DSH_SNAPSHOT` replay branch.** Rejected: it needs a test-only replay branch plus environment plumbing in the shipped CLI. The in-process scaffold already loads the same `apps/cli/cordis.yml`; only argv, profile JSON, and `AppCLIEntry` glue remain outside it, and the keyless CLI smokes cover those paths. +**Spawning the `dsh web` bin with a `DSH_SNAPSHOT` replay branch.** Rejected: it needs a test-only replay branch plus environment plumbing in the shipped CLI. The in-process scaffold already loads the same `apps/cli/base.cordis.yml` plus its surface overlay; only argv, profile JSON, and `AppCLIEntry` glue remain outside it, and the keyless CLI smokes cover those paths. **Changing the wire protocol for testability.** Rejected: the contract already has a first-class keyless isomorphic seam (`InProcessApiClient(toFetchHandler(api))`), the per-event unbatched SSE is exactly what makes replay observable in a browser, and testing a wire we no longer ship would invert the tier's purpose. diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index bad3dd15ed..6be7056b3d 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -16,7 +16,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu 一个普通的共享 fixture 模块([测试政策认可的形态](../../../../docs/testing.md)),不是包:值得门禁把守的逻辑——回放推导、会话解析、日志脱敏、持久化——都在已受门禁的包 `dsh-llm-replay`、`dsh-acp-snapshot`、`dsh-session-persistence-jsonl` 中;剩下的只是启动接线和浏览器胶水,而驱动 chromium 的源码在无浏览器的覆盖率 runner 上无法诚实保持逐文件 100% 覆盖率。 -`launchWebScaffold()` 通过 vendored Loader 的 include 机制,从交付的 `apps/cli/cordis.yml` 启动真实 web 组合——与 `AppCLIEntry` 为 `dsh web` 驱动的是同一棵树、同一套机制。差异全部经 include patch 覆盖在这棵树上,即 ACP `cordis.snapshot.yml` 模式的进程内表达:临时 `persistenceRoot`;禁用 `workspace-context`(录制的 fixture 不得嵌入本仓库的 AGENTS.md);禁用 `session-title-llm`(其发后不管的标题调用会与循环争抢会话的回放游标);webserver 行钉到端口 0 加已构建 dist;无密钥模式下禁用 `llm-deepseek`。patch 的 id 一旦不再匹配任何行,boot 扫描会大声失败而不是漂移。boot 在临时工作区 `chdir` 下运行,使 api-gateway 的 `process.cwd()` 会话默认值、工具 cwd 与 fixture 一致;`dsh web` bin 自身的胶水(argv、profile json、AppCLIEntry)仍由 `smoke-real.e2e.ts` 中的无密钥 CLI 冒烟把守。初始化回滚和正常关闭都会先对 Cordis 树执行 dispose(资源释放),再删除 scaffold 持有的两个临时根目录;每项清理都会独立尝试,并会报告清理失败而不掩盖初始化失败。 +`launchWebScaffold()` 通过 vendored Loader 的 include 机制,从交付的 `apps/cli/base.cordis.yml` plus its surface overlay 启动真实 web 组合——与 `AppCLIEntry` 为 `dsh web` 驱动的是同一棵树、同一套机制。差异全部经 include patch 覆盖在这棵树上,即 ACP `cordis.snapshot.yml` 模式的进程内表达:临时 `persistenceRoot`;禁用 `workspace-context`(录制的 fixture 不得嵌入本仓库的 AGENTS.md);禁用 `session-title-llm`(其发后不管的标题调用会与循环争抢会话的回放游标);webserver 行钉到端口 0 加已构建 dist;无密钥模式下禁用 `llm-deepseek`。patch 的 id 一旦不再匹配任何行,boot 扫描会大声失败而不是漂移。boot 在临时工作区 `chdir` 下运行,使 api-gateway 的 `process.cwd()` 会话默认值、工具 cwd 与 fixture 一致;`dsh web` bin 自身的胶水(argv、profile json、AppCLIEntry)仍由 `smoke-real.e2e.ts` 中的无密钥 CLI 冒烟把守。初始化回滚和正常关闭都会先对 Cordis 树执行 dispose(资源释放),再删除 scaffold 持有的两个临时根目录;每项清理都会独立尝试,并会报告清理失败而不掩盖初始化失败。 无密钥的模型替换 = 禁用适配器行的 patch 加 `installLlmReplay` 在停稳的根 ctx 上以提供方目录(providers-catalog)模式填充开放的 seam——绝不用 catch-all:适配器行被禁用后不存在任何适配器,catch-all 会让 `resolveModelInfo` 无路由可走,`compact-basic` 的步后压力检查将步步告警,而不是被可证明地闲置(发布的 128k `contextWindow` 使该路径对小 fixture 保持闲置)。选择直接安装而非插入回放插件行是刻意的:直接安装返回收尾消费检查所需的 `ReplayHandle`。没有 fixture 的场景让 seam 保持空置,任何离群的流式调用都会以 NO_ADAPTER 大声失败。 @@ -66,7 +66,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu **第二份提交的规范化会话日志预期输出。** 已否决:日志表面已由 ACP/headless/TUI 套件经同一循环与持久化钉住;在此只会翻倍刷新成本并重复测试下层。内联在根上下文事件上的世界状态断言保住了验证世界的义务。 -**以 `DSH_SNAPSHOT` 回放分支拉起 `dsh web` bin。** 已否决:它需要在交付的 CLI 中增加测试专用回放分支和环境变量管道。进程内 scaffold 已加载同一份 `apps/cli/cordis.yml`;只剩 argv、profile JSON 和 `AppCLIEntry` 胶水不在其覆盖范围内,而这些路径已由无密钥 CLI 冒烟覆盖。 +**以 `DSH_SNAPSHOT` 回放分支拉起 `dsh web` bin。** 已否决:它需要在交付的 CLI 中增加测试专用回放分支和环境变量管道。进程内 scaffold 已加载同一份 `apps/cli/base.cordis.yml` plus its surface overlay;只剩 argv、profile JSON 和 `AppCLIEntry` 胶水不在其覆盖范围内,而这些路径已由无密钥 CLI 冒烟覆盖。 **为可测试性改 wire 协议。** 已否决:契约已有第一等的无密钥同构 seam(`InProcessApiClient(toFetchHandler(api))`),逐事件不合批的 SSE 恰是回放在浏览器中可观测的原因,测试一条不再交付的 wire 会颠倒该层的存在意义。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index bb5f370009..ba60b83086 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 93c36d18abd06bbd7a80c918f520b92489180395 -README.zh.md: 85f4624a592eaf2ae44dc31fb4e18fb5657e62fd +README.md: 8b52c8bd5c5b7b75855e2bfbe28d061c5f3742b0 +README.zh.md: 0df55c52f723e901b090673b8fea663f38cbeaaf diff --git a/apps/cli/README.md b/apps/cli/README.md index 5241a29b4c..72b168ed98 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -8,7 +8,7 @@ Argv is parsed once through a [Commander](https://github.com/tj/commander.js) ad The TUI surface: -- boots the shipped default config (`examples/tui-agent/cordis.yml`), or the tree named by `--config ` (the demo/test escape for booting an alternate example tree), through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); +- boots the shipped default config (`apps/cli/base.cordis.yml`), or the tree named by `--config ` (the demo/test escape for booting an alternate example tree), through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); - resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume invocation; runtimes without process replacement leave the session running and say so. This CLI owns session identity and the exit line rather than the config: it mints or selects the `main` session id and provides it, plus the exact command that reproduces this invocation, on the boot context ([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) and `TUI_GOODBYE_MESSAGE_KEY`). No `cordis.yml` key can drop resume, and a missing or unreadable id fails loud instead of creating a fresh session; - treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh meta` is the sole exception, below); - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 69324735cb..dc0f73aaba 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -8,7 +8,7 @@ Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([` TUI 界面: -- 启动已交付的默认配置(`examples/tui-agent/cordis.yml`),或由 `--config ` 指定的树(演示/测试用于启动其他示例树的逃生口),并通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 完成启动; +- 启动已交付的默认配置(`apps/cli/base.cordis.yml`),或由 `--config ` 指定的树(演示/测试用于启动其他示例树的逃生口),并通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 完成启动; - 使用 `dsh --resume ` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的恢复调用替换进程;不支持进程替换的运行时会让会话继续运行并给出提示。会话身份与退出行由本 CLI 拥有,而非由配置指定:它创建或选定 `main` 会话 id,并把该 id 以及可复现本次调用的确切命令一起提供到启动上下文([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) 与 `TUI_GOODBYE_MESSAGE_KEY`)。任何 `cordis.yml` 键都无法移除恢复能力;缺失或无法读取的 id 会明确报错,而不会创建新会话; - 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文); - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; diff --git a/apps/cli/base.cordis.yml b/apps/cli/base.cordis.yml new file mode 100644 index 0000000000..8ece88a40d --- /dev/null +++ b/apps/cli/base.cordis.yml @@ -0,0 +1,210 @@ +# The shared `dsh` core: every row both the TUI (`tui.cordis.yml`) and the web +# surface (`web.cordis.yml`) mount identically. Neither surface includes the +# other — each is a patch list applied over THIS file at one include level, so a +# surface overlay, a `--config` overlay, and the personal `~/.dsh/config.yaml` +# all address these rows by id. Patch lists stack in that order, last write +# winning per row. +# +# A patch replaces the targeted row's whole `config` rather than merging into +# it, so a row whose value differs per surface does NOT live here: it belongs to +# each overlay, keeping any single row down to one overlay layer plus the user's. +# That is why `agent-loop`, `system-prompt`, `tools`, `fs-local`, and +# `llm-deepseek` thinking defaults are absent below. +# +# Row order carries no load semantics (activation is service-availability +# driven); the grouping is for readers. + +- id: timer + name: '@cordisjs/plugin-timer' + +- id: llm + name: '@deepseek-ai/dsh-llm' + +- id: session + name: '@deepseek-ai/dsh-session' + +- id: session-title + name: '@deepseek-ai/dsh-session-title' + config: + fallbackMaxWords: 5 + fallbackMaxBytes: 40 + maxTitleBytes: 80 + +- id: session-title-llm + name: '@deepseek-ai/dsh-session-title-first-message-llm' + config: + targetWords: 5 + targetCjkCharacters: 10 + maxInputBytes: 4096 + maxOutputTokens: 64 + timeoutMs: 60000 + +- id: user-interaction + name: '@deepseek-ai/dsh-user-interaction' + +- id: agent + name: '@deepseek-ai/dsh-agent' + +- id: tasks + name: '@deepseek-ai/dsh-tasks-local' + +- id: llm-retry + name: '@deepseek-ai/dsh-llm-retry' + +# The session store root is the launcher's policy, not a plugin's: `dsh` shares +# one store under the Harness home across every cwd, so `/resume` and +# `dsh ps` span workspaces. Without a launcher the project-local fallback keeps +# an embedder's sessions beside its project. +- id: session-persistence-jsonl + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js launcherSessionsRoot ?? './.sessions' + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: bash-local + name: '@deepseek-ai/dsh-bash-local' + config: + timeoutMs: 60000 + +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + +- id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + +- id: workspace-context + name: '@deepseek-ai/dsh-workspace-context' + config: + maxBytes: 65536 + +- id: skill + name: '@deepseek-ai/dsh-skill' + +- id: skill-local + name: '@deepseek-ai/dsh-skill-local' + +- id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + +- id: commands + name: '@deepseek-ai/dsh-commands' + +- id: goal + name: '@deepseek-ai/dsh-goal' + +- id: goal-session + name: '@deepseek-ai/dsh-goal-session' + +- id: command-goal + name: '@deepseek-ai/dsh-command-goal' + +- id: plan-mode + name: '@deepseek-ai/dsh-plan-mode' + config: + section: | + You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + + Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. + + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + + Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. + + Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. + + When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. + +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + +- id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn + +- id: subagent-fork + name: '@deepseek-ai/dsh-subagent-fork' + config: + providerName: fork + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + +- id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + +- id: workflow-workerthread + name: '@deepseek-ai/dsh-workflow-workerthread' + config: + provider: spawn + +- id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + +- id: timeout-policy + name: '@deepseek-ai/dsh-timeout-policy' + +- id: spill-local + name: '@deepseek-ai/dsh-spill-local' + +- id: spill-policy + name: '@deepseek-ai/dsh-spill-policy' + config: + maxInlineBytes: 50000 + +# ── rows every surface mounts, whose values each overlay states ────────────── + +# The tool registry. Presentation mode is a surface choice, so each overlay +# states it; omitting it here keeps the schema default (native). +- id: tools + name: '@deepseek-ai/dsh-tools' + +# The deployment persona is a surface choice; plan-mode and tool plugins own +# their own prompt sections. +- id: system-prompt + name: '@deepseek-ai/dsh-system-prompt' + config: + persona: '' + +# Agents created at startup. The TUI pre-creates `main`; the web surface creates +# sessions on client request, so its overlay keeps this empty. +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' + config: + agents: [] + +# The filesystem provider. `cwd` defaults to the package's `process.cwd()`; the +# TUI states it explicitly because that value is also the session workspace. +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + +# The native DeepSeek adapter; reads the key/base-url the boot's layered .env +# loading left in the environment. Thinking defaults are a surface choice. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL diff --git a/examples/tui-agent/composition.md b/apps/cli/composition.md similarity index 51% rename from examples/tui-agent/composition.md rename to apps/cli/composition.md index 1bd1b7298c..47d14ce88d 100644 --- a/examples/tui-agent/composition.md +++ b/apps/cli/composition.md @@ -1,38 +1,67 @@ -# TUI Agent App Composition +# dsh TUI Composition The TUI agent combines the real DeepSeek adapter, coding tools, compaction, subagents, and workflows with the full-screen terminal app package. ```mermaid flowchart LR - cfg["examples/tui-agent
cordis.yml"] - plugin_tui_hmr["hmr
@cordisjs/plugin-hmr"] - cfg --> plugin_tui_hmr - plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] - cfg --> plugin_tui_llm_deepseek - plugin_tui_llm_pi_ai["llm-pi-ai
@deepseek-ai/dsh-llm-pi-ai"] - cfg --> plugin_tui_llm_pi_ai - plugin_tui_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] - cfg --> plugin_tui_subprocess - plugin_tui_bash["bash
@deepseek-ai/dsh-bash-local"] - cfg --> plugin_tui_bash - plugin_tui_tui_agent["tui-agent
@deepseek-ai/dsh-tui-demo"] - cfg --> plugin_tui_tui_agent - plugin_tui_tui_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] - plugin_tui_tui_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_tui_tui_agent --> frontdoor_tui["@deepseek-ai/dsh-tui
pre-created main agent"] - bundle_agent_core --> spine_llm["ctx.llm"] - bundle_agent_core --> spine_sessions["ctx.sessions"] - bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] - bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] + cfg["apps/cli (dsh)
cordis.yml"] + plugin_tui_timer["timer
@cordisjs/plugin-timer"] + cfg --> plugin_tui_timer + plugin_tui_llm["llm
@deepseek-ai/dsh-llm"] + cfg --> plugin_tui_llm + plugin_tui_session["session
@deepseek-ai/dsh-session"] + cfg --> plugin_tui_session + plugin_tui_session_title["session-title
@deepseek-ai/dsh-session-title"] + cfg --> plugin_tui_session_title plugin_tui_session_title_llm["session-title-llm
@deepseek-ai/dsh-session-title-first-message-llm"] cfg --> plugin_tui_session_title_llm + plugin_tui_user_interaction["user-interaction
@deepseek-ai/dsh-user-interaction"] + cfg --> plugin_tui_user_interaction + plugin_tui_agent["agent
@deepseek-ai/dsh-agent"] + cfg --> plugin_tui_agent + plugin_tui_tasks["tasks
@deepseek-ai/dsh-tasks-local"] + cfg --> plugin_tui_tasks + plugin_tui_llm_retry["llm-retry
@deepseek-ai/dsh-llm-retry"] + cfg --> plugin_tui_llm_retry + plugin_tui_session_persistence_jsonl["session-persistence-jsonl
@deepseek-ai/dsh-session-persistence-jsonl"] + cfg --> plugin_tui_session_persistence_jsonl + plugin_tui_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] + cfg --> plugin_tui_subprocess + plugin_tui_bash_local["bash-local
@deepseek-ai/dsh-bash-local"] + cfg --> plugin_tui_bash_local + plugin_tui_tool_bash["tool-bash
@deepseek-ai/dsh-tool-bash"] + cfg --> plugin_tui_tool_bash + plugin_tui_tool_tasks["tool-tasks
@deepseek-ai/dsh-tool-tasks"] + cfg --> plugin_tui_tool_tasks + plugin_tui_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"] + cfg --> plugin_tui_fs_policy + plugin_tui_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] + cfg --> plugin_tui_tool_fs + plugin_tui_tool_fs_search["tool-fs-search
@deepseek-ai/dsh-tool-fs-search"] + cfg --> plugin_tui_tool_fs_search + plugin_tui_workspace_context["workspace-context
@deepseek-ai/dsh-workspace-context"] + cfg --> plugin_tui_workspace_context + plugin_tui_skill["skill
@deepseek-ai/dsh-skill"] + cfg --> plugin_tui_skill + plugin_tui_skill_local["skill-local
@deepseek-ai/dsh-skill-local"] + cfg --> plugin_tui_skill_local + plugin_tui_tool_skill["tool-skill
@deepseek-ai/dsh-tool-skill"] + cfg --> plugin_tui_tool_skill + plugin_tui_commands["commands
@deepseek-ai/dsh-commands"] + cfg --> plugin_tui_commands + plugin_tui_goal["goal
@deepseek-ai/dsh-goal"] + cfg --> plugin_tui_goal + plugin_tui_goal_session["goal-session
@deepseek-ai/dsh-goal-session"] + cfg --> plugin_tui_goal_session + plugin_tui_command_goal["command-goal
@deepseek-ai/dsh-command-goal"] + cfg --> plugin_tui_command_goal + plugin_tui_plan_mode["plan-mode
@deepseek-ai/dsh-plan-mode"] + cfg --> plugin_tui_plan_mode plugin_tui_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] cfg --> plugin_tui_token_meter - plugin_tui_tool_result_prune["tool-result-prune
@deepseek-ai/dsh-compact-tool-result-prune"] - cfg --> plugin_tui_tool_result_prune plugin_tui_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] cfg --> plugin_tui_compact_basic plugin_tui_subagent["subagent
@deepseek-ai/dsh-subagent"] @@ -49,39 +78,53 @@ flowchart LR cfg --> plugin_tui_workflow_workerthread plugin_tui_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"] cfg --> plugin_tui_tool_workflow - plugin_tui_tool_ralph["tool-ralph
@deepseek-ai/dsh-tool-ralph"] - cfg --> plugin_tui_tool_ralph - plugin_tui_plan_mode["plan-mode
@deepseek-ai/dsh-plan-mode"] - cfg --> plugin_tui_plan_mode - plugin_tui_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] - cfg --> plugin_tui_fs_local - plugin_tui_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"] - cfg --> plugin_tui_fs_policy - plugin_tui_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] - cfg --> plugin_tui_tool_fs - plugin_tui_source_guard["source-guard
@deepseek-ai/dsh-source-guard"] - cfg --> plugin_tui_source_guard - plugin_tui_tool_fs_search["tool-fs-search
@deepseek-ai/dsh-tool-fs-search"] - cfg --> plugin_tui_tool_fs_search plugin_tui_timeout_policy["timeout-policy
@deepseek-ai/dsh-timeout-policy"] cfg --> plugin_tui_timeout_policy plugin_tui_spill_local["spill-local
@deepseek-ai/dsh-spill-local"] cfg --> plugin_tui_spill_local plugin_tui_spill_policy["spill-policy
@deepseek-ai/dsh-spill-policy"] cfg --> plugin_tui_spill_policy + plugin_tui_tools["tools
@deepseek-ai/dsh-tools"] + cfg --> plugin_tui_tools + plugin_tui_system_prompt["system-prompt
@deepseek-ai/dsh-system-prompt"] + cfg --> plugin_tui_system_prompt + plugin_tui_agent_loop["agent-loop
@deepseek-ai/dsh-agent-loop"] + cfg --> plugin_tui_agent_loop + plugin_tui_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] + cfg --> plugin_tui_fs_local + plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] + cfg --> plugin_tui_llm_deepseek ``` | Plugin id | Package / module | | --- | --- | -| `hmr` | `@cordisjs/plugin-hmr` | -| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -| `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` | -| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | -| `bash` | `@deepseek-ai/dsh-bash-local` | -| `tui-agent` | `@deepseek-ai/dsh-tui-demo` | +| `timer` | `@cordisjs/plugin-timer` | +| `llm` | `@deepseek-ai/dsh-llm` | +| `session` | `@deepseek-ai/dsh-session` | +| `session-title` | `@deepseek-ai/dsh-session-title` | | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` | +| `user-interaction` | `@deepseek-ai/dsh-user-interaction` | +| `agent` | `@deepseek-ai/dsh-agent` | +| `tasks` | `@deepseek-ai/dsh-tasks-local` | +| `llm-retry` | `@deepseek-ai/dsh-llm-retry` | +| `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` | +| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | +| `bash-local` | `@deepseek-ai/dsh-bash-local` | +| `tool-bash` | `@deepseek-ai/dsh-tool-bash` | +| `tool-tasks` | `@deepseek-ai/dsh-tool-tasks` | +| `fs-policy` | `@deepseek-ai/dsh-fs-policy` | +| `tool-fs` | `@deepseek-ai/dsh-tool-fs` | +| `tool-fs-search` | `@deepseek-ai/dsh-tool-fs-search` | +| `workspace-context` | `@deepseek-ai/dsh-workspace-context` | +| `skill` | `@deepseek-ai/dsh-skill` | +| `skill-local` | `@deepseek-ai/dsh-skill-local` | +| `tool-skill` | `@deepseek-ai/dsh-tool-skill` | +| `commands` | `@deepseek-ai/dsh-commands` | +| `goal` | `@deepseek-ai/dsh-goal` | +| `goal-session` | `@deepseek-ai/dsh-goal-session` | +| `command-goal` | `@deepseek-ai/dsh-command-goal` | +| `plan-mode` | `@deepseek-ai/dsh-plan-mode` | | `token-meter` | `@deepseek-ai/dsh-token-meter` | -| `tool-result-prune` | `@deepseek-ai/dsh-compact-tool-result-prune` | | `compact-basic` | `@deepseek-ai/dsh-compact-basic` | | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | @@ -90,17 +133,15 @@ flowchart LR | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | | `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | | `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | -| `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` | -| `plan-mode` | `@deepseek-ai/dsh-plan-mode` | -| `fs-local` | `@deepseek-ai/dsh-fs-local` | -| `fs-policy` | `@deepseek-ai/dsh-fs-policy` | -| `tool-fs` | `@deepseek-ai/dsh-tool-fs` | -| `source-guard` | `@deepseek-ai/dsh-source-guard` | -| `tool-fs-search` | `@deepseek-ai/dsh-tool-fs-search` | | `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` | | `spill-local` | `@deepseek-ai/dsh-spill-local` | | `spill-policy` | `@deepseek-ai/dsh-spill-policy` | +| `tools` | `@deepseek-ai/dsh-tools` | +| `system-prompt` | `@deepseek-ai/dsh-system-prompt` | +| `agent-loop` | `@deepseek-ai/dsh-agent-loop` | +| `fs-local` | `@deepseek-ai/dsh-fs-local` | +| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -Source config: [`examples/tui-agent/cordis.yml`](cordis.yml). +Source config: [`apps/cli/base.cordis.yml`](base.cordis.yml). Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml deleted file mode 100644 index ed44f75c7f..0000000000 --- a/apps/cli/cordis.yml +++ /dev/null @@ -1,420 +0,0 @@ -# dsh web — the full web-shape composition: host runtime (layer 1), the -# transport/service layer (layer 2), and the browser plugin roster (dshClient -# rows the modules node half scans into window.__DSH_BOOT__). Row order -# carries no load semantics (activation is service-availability driven); the -# grouping below is for readers. `--dev` appends the dsh-client-hmr row in -# code (AppCLIEntry) — prod and dev differ by exactly that one row. -# AppCLIEntry patches this tree before boot: profile json + CLI flags + -# distIndex land as config patches over the rows below (yaml = engineering -# defaults, json = user config, user wins per field). - -# ── layer 1: runtime ──────────────────────────────────────────────────────── - -- id: timer - name: '@cordisjs/plugin-timer' - -- id: llm - name: '@deepseek-ai/dsh-llm' - -- id: session - name: '@deepseek-ai/dsh-session' - -# Projection registry: drives every registered domain unit over committed -# session events and serves finished values (history-tail projections block + -# session/projection frames). Without this row every domain's optional unit -# injection stays silent — no block, no frames, no titles/todos on the web. -- id: session-projection - name: '@deepseek-ai/dsh-session-projection' - -- id: session-title - name: '@deepseek-ai/dsh-session-title' - config: - fallbackMaxWords: 5 - fallbackMaxBytes: 40 - maxTitleBytes: 80 - -# Model-made titles on the first-message cadence (the web sidebar renders -# session/title). Same values as the TUI composition. -- id: session-title-llm - name: '@deepseek-ai/dsh-session-title-first-message-llm' - config: - targetWords: 5 - targetCjkCharacters: 10 - maxInputBytes: 4096 - maxOutputTokens: 64 - timeoutMs: 60000 - -- id: system-prompt - name: '@deepseek-ai/dsh-system-prompt' - config: - persona: '' - -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - # TEMPORARY workaround: DSH_TOOLS_MODE (native|code|both) opts a whole dsh - # process into Code Mode while per-session tool-mode selection is being - # designed; unset keeps the schema default (native). Remove the env seam - # once the web UI owns the choice per session. - mode: !!js process.env.DSH_TOOLS_MODE - -# Code Mode substrate for the row above. Mounted unconditionally because -# Loader metadata is static (no conditional rows): a native-mode boot only -# registers the service — a worker thread spawns per run_code execution. -- id: code-runtime - name: '@deepseek-ai/dsh-code-runtime-worker' - -- id: user-interaction - name: '@deepseek-ai/dsh-user-interaction' - -- id: agent - name: '@deepseek-ai/dsh-agent' - -- id: tasks - name: '@deepseek-ai/dsh-tasks-local' - -- id: agent-loop - name: '@deepseek-ai/dsh-agent-loop' - config: - agents: [] - -# The native DeepSeek adapter; reads the key/base-url the boot's layered -# .env loading (cwd then $DSH_HOME) left in the environment. -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - -# Common pi-ai provider routes read credentials and endpoint overrides from the -# boot's layered environment. -- id: llm-pi-ai - name: '@deepseek-ai/dsh-llm-pi-ai' - config: - providers: - - provider: openai - apiKey: !!js process.env.OPENAI_API_KEY - baseURL: !!js process.env.OPENAI_BASE_URL - - provider: anthropic - apiKey: !!js process.env.ANTHROPIC_API_KEY - baseURL: !!js process.env.ANTHROPIC_BASE_URL - -# Transient-failure recovery around the loop's model calls (same policy as -# the TUI's agent-spine composition; defaults: 2 retries, 500ms→10s backoff). -- id: llm-retry - name: '@deepseek-ai/dsh-llm-retry' - -# Session store root. AppCLIEntry resolves the engineering default to a -# global dir under the Harness home ($DSH_HOME, else ~/.dsh): sessions live -# in one place across every cwd, not a project-local ./.sessions. The -# persistenceRoot profile key (user config) still overrides this per field. -- id: session-persistence-jsonl - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: './.sessions' - -- id: storage - name: '@deepseek-ai/dsh-storage' - -- id: storage-json - name: '@deepseek-ai/dsh-storage-json' - config: - root: './.storages' - -- id: storage-domain - name: '@deepseek-ai/dsh-storage-domain' - config: - backend: json - -- id: workspace - name: '@deepseek-ai/dsh-workspace' - -# Persisted projection cache: durable per-session checkpoints of every -# registered projection unit (json backend → ./.storages/session_projcache.json, -# beside workspace.json), throttled between the two mandatory points -# (turn/end + detach), serving cold listings without full-log loads. -- id: session-projection-cache - name: '@deepseek-ai/dsh-session-projection-cache' - config: - writeEveryEvents: 200 - writeIntervalMs: 5000 - -# Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' - -# The sandboxed product path (the acp-agent composition): per-platform -# runner provider, the shared policy home, the confined bash executor, and -# the approval seam its escalation asks through. The web deployment default -# is danger-full-access + never (same behavior as the former bash-local -# rows); DSH_PERMISSION_MODE opts a process into a confined default, and -# per-session switches ride the /permission command's knob events. -- id: sandbox - name: '@deepseek-ai/dsh-sandbox-local' - -- id: sandbox-policy - name: '@deepseek-ai/dsh-sandbox-policy' - config: - mode: !!js process.env.DSH_PERMISSION_MODE ?? 'danger-full-access' - workspaceRoot: !!js process.cwd() - -- id: bash-sandbox - name: '@deepseek-ai/dsh-bash-sandbox' - -- id: approval - name: '@deepseek-ai/dsh-user-approval' - config: - policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'danger-full-access') === 'danger-full-access' ? 'never' : 'ask'" - -# Presets over the two knobs (requires the confining executor + approval): -# the web permission chip's table, served through the permissions projection -# and switched through /permission. -- id: permission - name: '@deepseek-ai/dsh-permission' - config: - presets: - read-only: - sandbox: read-only - approval: ask - workspace-write: - sandbox: workspace-write - approval: ask - danger-full-access: - sandbox: danger-full-access - approval: never - -- id: tool-bash - name: '@deepseek-ai/dsh-tool-bash' - -- id: tool-todo - name: '@deepseek-ai/dsh-tool-todo' - -- id: tool-tasks - name: '@deepseek-ai/dsh-tool-tasks' - -# fs cwd stays the package default (process.cwd()) — the same value the -# gateway injects into session.cwd, so paths and sessions agree. The -# sandboxed backend rides the SAME policy as bash: write/edit fence by the -# effective mode, so read/write/edit stay available under every mode. -- id: fs-sandbox - name: '@deepseek-ai/dsh-fs-sandbox' - -- id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - -- id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' - -- id: tool-fs-search - name: '@deepseek-ai/dsh-tool-fs-search' - -- id: workspace-context - name: '@deepseek-ai/dsh-workspace-context' - config: - maxBytes: 65536 - -- id: skill - name: '@deepseek-ai/dsh-skill' - -- id: skill-local - name: '@deepseek-ai/dsh-skill-local' - -- id: tool-skill - name: '@deepseek-ai/dsh-tool-skill' - -# Host command registry: the single source of truth behind command.list / -# command.execute; the web '/' menu is a pure projection of this registry. -- id: commands - name: '@deepseek-ai/dsh-commands' - -# Goal service + automatic same-session continuation + the /goal command. -# The GoalService registers the 'goal' session projection unit; the web -# GoalBar reads it through useProjection. -- id: goal - name: '@deepseek-ai/dsh-goal' - -- id: goal-session - name: '@deepseek-ai/dsh-goal-session' - -- id: command-goal - name: '@deepseek-ai/dsh-command-goal' - -# Plan mode registers /plan (the first real command on the web surface). -# Section text mirrors examples/tui-agent/cordis.yml (the reference -# deployment); plan-mode throws at load on an empty section. -- id: plan-mode - name: '@deepseek-ai/dsh-plan-mode' - config: - section: | - You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. - - Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. - - The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. - - Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. - - Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. - - When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. - -# token-meter rejects unknown config keys — keep this row bare. -- id: token-meter - name: '@deepseek-ai/dsh-token-meter' - -- id: compact-basic - name: '@deepseek-ai/dsh-compact-basic' - -- id: subagent - name: '@deepseek-ai/dsh-subagent' - -- id: subagent-spawn - name: '@deepseek-ai/dsh-subagent-spawn' - config: - providerName: spawn - -- id: subagent-fork - name: '@deepseek-ai/dsh-subagent-fork' - config: - providerName: fork - -- id: tool-subagent - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent - -- id: tool-subagent-fork - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: fork - toolName: subagent_fork - -- id: workflow-workerthread - name: '@deepseek-ai/dsh-workflow-workerthread' - config: - provider: spawn - -- id: tool-workflow - name: '@deepseek-ai/dsh-tool-workflow' - -- id: timeout-policy - name: '@deepseek-ai/dsh-timeout-policy' - -- id: spill-local - name: '@deepseek-ai/dsh-spill-local' - -# Omitting maxInlineBytes makes the whole policy a silent no-op — always -# state it explicitly. -- id: spill-policy - name: '@deepseek-ai/dsh-spill-policy' - config: - maxInlineBytes: 50000 - -# The API gateway: the transport-agnostic dispatch face every client shape -# shares. provider/model are the host default routing — the profile json's -# mapping target (user config overrides these engineering defaults). -# Directory-picking package, dual-face: the node half serves the gateway's -# host.* picker RPCs, the browser half fills ui-workspace's directory-flow -# slots — one row composes the whole interaction. Swap point: mount -# '-native' instead for the host-display OS chooser. -- id: directory-picker - name: '@deepseek-ai/dsh-host-directory-picker-browse' - -- id: api-gateway - name: '@deepseek-ai/dsh-host-apiproxy' - config: - provider: deepseek - model: deepseek-v4-flash - -# ── layer 2: transport/service ────────────────────────────────────────────── - -# Plain route-registration carrier. distIndex is an assembly fact, not user -# config — AppCLIEntry resolves the frontend dist and patches it in; host and -# port arrive as CLI-flag patches over these defaults. -- id: webserver - name: '@deepseek-ai/dsh-host-webserver' - config: - host: 127.0.0.1 - port: 3080 - -# ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ── - -# Dual-face: node half scans this very tree for dshClient rows, composes -# window.__DSH_BOOT__, serves /plugins//client.js; browser half is the -# module table the shell kernel constructs before cordis exists (§4.7 — -# adopted as a plugin entry by the kernel, never fetched). -- id: modules - name: '@deepseek-ai/dsh-client-modules' - -# Owns both ends of the web transport: node half binds the gateway to the -# webserver under /api; browser half is the fetch/SSE client. -- id: connection - name: '@deepseek-ai/dsh-client-connection' - -- id: client-runtime - name: '@deepseek-ai/dsh-client-runtime' - -- id: ui-theme - name: '@deepseek-ai/dsh-client-ui-theme' - -- id: locale - name: '@deepseek-ai/dsh-client-locale' - -- id: ui-layout - name: '@deepseek-ai/dsh-client-ui-layout' - -- id: ui-sidebar - name: '@deepseek-ai/dsh-client-ui-sidebar' - -- id: ui-settings - name: '@deepseek-ai/dsh-client-ui-settings' - -- id: ui-settings-general - name: '@deepseek-ai/dsh-client-ui-settings-general' - -- id: ui-models - name: '@deepseek-ai/dsh-client-ui-models' - -- id: ui-conversation - name: '@deepseek-ai/dsh-client-ui-conversation' - - -- id: ui-workspace - name: '@deepseek-ai/dsh-client-ui-workspace' - -# Input triggers: the '/' | '@' pipeline (ui-slash), the command surface over -# it (ui-command), and the two reference sources (ui-skill / ui-subagent). -- id: ui-slash - name: '@deepseek-ai/dsh-client-ui-slash' - -- id: ui-command - name: '@deepseek-ai/dsh-client-ui-command' - -- id: ui-skill - name: '@deepseek-ai/dsh-client-ui-skill' - -- id: ui-subagent - name: '@deepseek-ai/dsh-client-ui-subagent' - -# Goal surface: GoalBar in the input dock over the goal session projection. -- id: ui-goal - name: '@deepseek-ai/dsh-client-ui-goal' - -# Model selection: the /model popupSelect + composer seat over session.models. -- id: ui-model - name: '@deepseek-ai/dsh-client-ui-model' - -# The /permission popup picker (hostBacked over the host /permission command). -- id: ui-permission - name: '@deepseek-ai/dsh-client-ui-permission' - -# Plan control: the composer plan seat over the plan projection + /plan channel. -- id: ui-plan - name: '@deepseek-ai/dsh-client-ui-plan' - -- id: ui-question - name: '@deepseek-ai/dsh-client-ui-question' - -- id: ui-trajectory - name: '@deepseek-ai/dsh-client-ui-trajectory' diff --git a/apps/cli/package.json b/apps/cli/package.json index 783fff6487..c815cf037a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -110,6 +110,7 @@ "js-yaml": "^4.2.0" }, "devDependencies": { - "@types/js-yaml": "^4.0.9" + "@types/js-yaml": "^4.0.9", + "node-pty": "1.1.0" } } diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 02bf596403..c0e67bae00 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -17,7 +17,7 @@ import type { FiberState } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include, { type PatchOptions } from '@cordisjs/plugin-include' import yaml from 'js-yaml' -import { assertEntriesLoaded, installFailLoud, loadEnv } from '@deepseek-ai/dsh-app-boot' +import { assertEntriesLoaded, installFailLoud, loadEnv, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome, resolveSessionsRoot } from '@deepseek-ai/dsh-paths' // Empty type import carries the httpServer Context merge for the port read below. import type {} from '@deepseek-ai/dsh-host-webserver' @@ -100,8 +100,21 @@ const FIBER_PENDING = 0 as FiberState.PENDING /** Constructor facts for one dsh invocation over the shared composition (argv already parsed by the surface bin). */ export interface AppCLIEntryOptions { - /** Absolute path of the shipped cordis.yml. */ + /** Absolute path of the shared base config the Loader includes. */ configPath: string + /** + * Absolute path of this surface's overlay: a patch list applied over + * {@link configPath} before this entry's own profile/flag patches. Its rows + * are also merge inputs, so a flag override preserves the overlay's other + * fields on the same row. + */ + overlayPath: string + /** + * Optional extra overlay applied after {@link overlayPath} and before this + * entry's own profile/flag patches — the `--config` escape for demos and + * tests that need a row this surface does not ship. + */ + extraOverlayPath?: string /** Whether to append the HMR row (the whole prod/dev difference; web surface only). */ dev: boolean /** --host when explicitly passed; undefined keeps the yml engineering default. */ @@ -223,11 +236,22 @@ export class AppCLIEntry { ctx.baseUrl = pathToFileURL(join(resolve(this.options.configPath), '..')).href + '/' await ctx.plugin(Loader) ctx.loader.builtins.include = Include + // One include of the shared base with every overlay as a sibling patch + // list: patches never cross an include boundary, so nesting them would + // silently stop reaching base rows. The surface overlay applies first, then + // this entry's profile-json and CLI-flag patches, which therefore win. + const patches = [ + ...loadOverlayPatches('dsh', this.options.overlayPath), + ...this.options.extraOverlayPath === undefined + ? [] + : loadOverlayPatches('dsh', this.options.extraOverlayPath), + ...this.patches, + ] await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(resolve(this.options.configPath)).href, - ...this.patches.length > 0 ? { patches: this.patches } : {}, + ...patches.length > 0 ? { patches } : {}, }, }) if (this.options.dev) { @@ -262,17 +286,39 @@ export class AppCLIEntry { } } - /** Bypass parse of the shipped yml (id → row) for patch-merge inputs; Loader still reads the file itself. */ + /** + * Bypass parse of the base and this surface's overlay (id → row) for + * patch-merge inputs; the Loader still reads both files itself. The overlay + * wins per row, matching the order its patches are applied in, and its + * `insert` rows are indexed too because a flag may target one of them. + */ private parseYmlRows(): Map { - const doc = yaml.load(readFileSync(this.options.configPath, 'utf8'), { schema: includeYamlSchema }) - if (!Array.isArray(doc)) throw new Error(`dsh: ${this.options.configPath} is not a top-level entry list`) const rows = new Map() - for (const row of doc as { id?: string; config?: unknown }[]) { - if (typeof row.id === 'string') rows.set(row.id, row) + const files = [this.options.configPath, this.options.overlayPath] + if (this.options.extraOverlayPath !== undefined) files.push(this.options.extraOverlayPath) + for (const file of files) { + for (const row of this.parseRowList(file)) { + if (typeof row.id === 'string') rows.set(row.id, row) + for (const inserted of row.insert ?? []) { + if (typeof inserted.id === 'string') rows.set(inserted.id, inserted) + } + } } return rows } + /** + * Parse one entry or patch list, rejecting anything that is not a top-level + * array so a malformed file fails here rather than at row lookup. + * @param file - absolute path of the config or overlay file. + * @returns the parsed top-level entries. + */ + private parseRowList(file: string): { id?: string; config?: unknown; insert?: { id?: string; config?: unknown }[] }[] { + const doc = yaml.load(readFileSync(file, 'utf8'), { schema: includeYamlSchema }) + if (!Array.isArray(doc)) throw new Error(`dsh: ${file} is not a top-level entry list`) + return doc as { id?: string; config?: unknown; insert?: { id?: string; config?: unknown }[] }[] + } + /** Profile json under cwd; read-only — never created here, absent = no user config. */ private readProfile(): Record { let raw: string diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index d143430ed4..16cf22eda0 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -11,10 +11,15 @@ import { Command, CommanderError } from 'commander' -/** Interactive TUI: the default mode. `--config` swaps the tree; `--resume ` rehydrates a session. */ +/** + * Interactive TUI: the default mode. `--config` applies an overlay over the + * shipped composition in place of the personal one, `--config-replace` boots a + * file as the whole tree instead, and `--resume ` rehydrates a session. + */ interface TuiInvocation { mode: 'tui' config?: string + configReplace?: string resume?: string } @@ -69,6 +74,8 @@ interface ListSessionsInvocation { */ interface WebInvocation { mode: 'web' + /** Overlay of loader patches applied over the shipped web composition. */ + config?: string host?: string port?: number dev: boolean @@ -88,6 +95,7 @@ export type DshInvocation = /** Raw web-subcommand options straight from Commander. */ interface WebOptions { + config?: string host?: string port?: string dev?: boolean @@ -104,6 +112,7 @@ interface WebOptions { function resolveWeb(options: WebOptions): WebInvocation { return { mode: 'web', + ...options.config !== undefined && { config: options.config }, ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, dev: options.dev === true, @@ -140,15 +149,16 @@ Examples: // subcommand without a positional collision. .option('-p, --prompt ', 'answer this task without the interactive UI, then exit') .option('--resume ', 'continue a past session by id (list ids with `dsh ps`)') - .option('--config ', 'start with an alternate plugin configuration file') - .action((options: { config?: string; prompt?: string; resume?: string }) => { + .option('--config ', 'apply this overlay of loader patches instead of the personal one') + .option('--config-replace ', 'boot this file as the entire tree, ignoring the shipped and personal configuration') + .action((options: { config?: string; configReplace?: string; prompt?: string; resume?: string }) => { if (options.prompt !== undefined) { // A headless prompt owns the invocation; an empty task has nothing to // run, and --config/--resume are TUI inputs that must not silently // vanish from a headless run. if (options.prompt === '') program.error('error: --prompt needs a task') - if (options.config !== undefined || options.resume !== undefined) { - program.error('error: --prompt takes no --config or --resume') + if (options.config !== undefined || options.configReplace !== undefined || options.resume !== undefined) { + program.error('error: --prompt takes no --config, --config-replace, or --resume') } resolved = { mode: 'headless', prompt: options.prompt } return @@ -156,9 +166,15 @@ Examples: // An empty --resume= id would silently start a fresh session downstream // (agent-loop treats '' as no-resume), so a mistyped resume must fail loud. if (options.resume === '') program.error('error: --resume needs a session id') + // The two config flags are mutually exclusive: one layers over the shipped + // tree, the other discards it, so accepting both would silently drop one. + if (options.config !== undefined && options.configReplace !== undefined) { + program.error('error: --config and --config-replace are mutually exclusive') + } resolved = { mode: 'tui', ...options.config !== undefined && { config: options.config }, + ...options.configReplace !== undefined && { configReplace: options.configReplace }, ...options.resume !== undefined && { resume: options.resume }, } }) @@ -168,8 +184,9 @@ Examples: // a leaked `--config`/`-p`/`--resume` is a mistyped invocation that must fail // loud rather than silently run and drop the input. const rejectParentOptions = (command: string): void => { - const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>() - if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) { + const parent = program.opts<{ config?: string; configReplace?: string; prompt?: string; resume?: string }>() + if (parent.config !== undefined || parent.configReplace !== undefined + || parent.prompt !== undefined || parent.resume !== undefined) { program.error(`error: ${command} takes none of --config, -p/--prompt, or --resume`) } } @@ -208,6 +225,7 @@ Examples: // would duplicate a fact this file does not own. const web = program.command('web').description('serve the browser UI on the configured host and port') web + .option('--config ', 'apply this overlay of loader patches over the shipped configuration') .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') .option('--port ', 'listen port; pass 0 to let the OS pick a free one') .option('--dev', 'developer mode: hot-reload the browser client') diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 8079db1cd4..caff90d7f2 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -30,7 +30,7 @@ const invocation = parseDshArgs(process.argv.slice(2), readVersion()) switch (invocation.mode) { case 'web': { const { runWeb } = await import('./web.ts') - await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts) + await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts, invocation.config) break } case 'headless': { @@ -40,7 +40,7 @@ switch (invocation.mode) { } case 'tui': { const { runTui } = await import('./tui.ts') - await runTui(invocation.config, invocation.resume) + await runTui(invocation.config, invocation.resume, undefined, undefined, invocation.configReplace) break } case 'meta': { diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 7ee18c1ea1..7c73623907 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -76,7 +76,8 @@ async function consumeUntilTurnEnd(frames: AsyncIterable>, export async function runHeadless(task: string): Promise { // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). const entry = new AppCLIEntry({ - configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), + configPath: fileURLToPath(new URL('../base.cordis.yml', import.meta.url)), + overlayPath: fileURLToPath(new URL('../web.cordis.yml', import.meta.url)), dev: false, port: 0, }) diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index fb1c354d46..c65f01fbc3 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -25,11 +25,13 @@ import { boot, installFailLoud, loadEnv, + loadOverlayPatches, loadPersonalPatches, resolveConfigPath, } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome, resolveSessionsRoot } from '@deepseek-ai/dsh-paths' import { SessionId } from '@deepseek-ai/dsh-session' +import { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop' import type { Context } from 'cordis' import { registerLiveSessions } from './register-session.ts' import { @@ -46,7 +48,18 @@ const NAME = 'dsh' // Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit // one directory under apps/cli, so the shipped default config resolves with // the same relative hop from either artifact. -const DEFAULT_CONFIG = fileURLToPath(new URL('../../../examples/tui-agent/cordis.yml', import.meta.url)) +// The shared core every `dsh` surface mounts, and the TUI's own overlay over +// it. Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) +// sit one directory under apps/cli, so each resolves with the same hop. +const BASE_CONFIG = fileURLToPath(new URL('../base.cordis.yml', import.meta.url)) +const TUI_OVERLAY = fileURLToPath(new URL('../tui.cordis.yml', import.meta.url)) + +// The `agents` entry in tui.cordis.yml the TUI drives; the launcher binds its +// session identity by this config id. +const MAIN_AGENT_ID = 'main' + +/** Filename of the derived `/resume` index, kept beside the session logs. */ +const SESSION_QUERY_DB = 'session-query.db' // The harness checkout root: three hops up from apps/cli/{src,lib}, resolved // from this bin's location so it holds however `dsh` is launched (a PATH @@ -93,24 +106,31 @@ export async function runSkillSession(skill: string): Promise { /** * Run the interactive TUI from the invoking directory. - * @param config - a config path to boot instead of the shipped default, or - * `undefined` for the default; already parsed from `--config`. + * @param config - an overlay patch list applied over the shared base and the + * TUI overlay, REPLACING the personal `~/.dsh/config.yaml` so a named tree never + * inherits the user's route, or `undefined` to use the personal overlay; + * already parsed from `--config`. * @param resumeSessionId - a persisted session id to resume, or `undefined` to * mint a fresh one; already parsed and non-empty-validated from `--resume`. * Either way the resulting identity reaches the booted app through - * {@link MAIN_SESSION_ID_KEY}, so no config key selects the session. + * {@link CONFIGURED_AGENT_IDENTITIES_KEY}, so no config key selects the session + * and an overlay replacing the agent row cannot drop it. * @param workspace - a directory to make the workspace instead of the invoking * one, or `undefined` to keep the cwd. Only `dsh meta` passes it. * @param initialSkill - a bundled skill to auto-invoke as a fresh session's * first turn, or `undefined`. Set only by {@link runSkillSession} and ignored * on a resume, so it never re-fires; reaches the app through * {@link INITIAL_SKILL_KEY}. + * @param configReplace - a config path to boot as the ENTIRE tree, bypassing the + * shared base, the TUI overlay, and the personal overlay alike, or `undefined` + * to compose them; already parsed from `--config-replace`. */ export async function runTui( config: string | undefined, resumeSessionId: string | undefined, workspace?: string, initialSkill?: string, + configReplace?: string, ): Promise { // Refuse pipes BEFORE booting: a compose-time throw inside the Loader tree // is logged per-entry rather than rethrown, so a piped launch would @@ -150,7 +170,13 @@ export async function runTui( const resumeArgs = (sessionId: string, targetCwd?: string): string[] => workspace !== undefined && (targetCwd === undefined || targetCwd === workspace) ? ['meta', `--resume=${sessionId}`] - : [`--resume=${sessionId}`, ...config !== undefined ? ['--config', config] : []] + : [ + `--resume=${sessionId}`, + // Both config flags must survive the handoff: resuming into a different + // tree than the session was created in would silently change the agent. + ...config !== undefined ? ['--config', config] : [], + ...configReplace !== undefined ? ['--config-replace', configReplace] : [], + ] // Mint the fresh id here rather than in the app bundle: the exit line names // the session to resume, so the launcher must know it before the tree boots. const identity: MainSessionIdentity = resumeSessionId === undefined @@ -186,10 +212,25 @@ export async function runTui( } }, } + // One include of the shared base, with every overlay applied as a sibling + // patch list: patches never cross an include boundary, so stacking these as + // nested includes would silently stop reaching base rows. Later lists win. + // + // `--config` REPLACES the personal overlay rather than layering under it: an + // explicitly named tree must not inherit `~/.dsh/config.yaml`'s route, or a + // demo or test config would silently run on the user's provider and model. + // `--config-replace` additionally discards the base and the surface overlay. + const replaceTree = configReplace !== undefined + const patches = replaceTree ? [] : [ + ...loadOverlayPatches(NAME, TUI_OVERLAY), + ...config === undefined + ? loadPersonalPatches(NAME) ?? [] + : loadOverlayPatches(NAME, resolveConfigPath(config, undefined)), + ] const ctx = await boot( NAME, - resolveConfigPath(config ?? DEFAULT_CONFIG, undefined), - loadPersonalPatches(NAME), + replaceTree ? resolveConfigPath(configReplace, undefined) : BASE_CONFIG, + patches, (hostCtx) => { // The launcher owns session identity and the exit line: a config-mounted // app bundle reads both from these slots, so no cordis.yml key can drop @@ -200,6 +241,13 @@ export async function runTui( // the Harness home across every cwd, so /resume and list-sessions see // every workspace. The bundle treats the slot as opaque. hostCtx.provide(SESSIONS_ROOT_KEY, launcherSessionsRoot()) + // The agent-loop row reads this to bind `main`, and the tui row reads the + // same id, so a personal overlay repointing the model route cannot drop + // the session identity or desynchronise the two. + hostCtx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, { [MAIN_AGENT_ID]: identity }) + // The launcher owns the session store location, so it also owns the + // derived index path that must sit beside those logs. + hostCtx.provide('launcherSessionQueryPath', join(launcherSessionsRoot(), SESSION_QUERY_DB)) if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost) // Seed the first turn only for a fresh session, so resuming never // re-invokes the skill. diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index f4ad016005..24d21a6eb4 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -7,10 +7,13 @@ */ import { fileURLToPath } from 'node:url' +import { resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import { AppCLIEntry } from './app-cli-entry.ts' import { registerLiveSessions } from './register-session.ts' -const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) +// The shared core every `dsh` surface mounts, plus this surface's overlay over it. +const BASE_CONFIG = fileURLToPath(new URL('../base.cordis.yml', import.meta.url)) +const WEB_OVERLAY = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) // Display-only mirror of the webserver schema's loopback host: the address the // local URL always prints. Not a source of truth — the schema is. @@ -24,6 +27,8 @@ const LOOPBACK_HOST = '127.0.0.1' * @param dev - mount the client HMR driver and watch plugin bundles for rebuilds. * @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback. * @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone. + * @param config - an overlay of loader patches applied over the shipped web + * composition, or `undefined` for none; already parsed from `--config`. */ export async function runWeb( host: string | undefined, @@ -31,9 +36,12 @@ export async function runWeb( dev: boolean, workspaceRoot: string | undefined, trustedHosts: string[] | undefined, + config?: string, ): Promise { const entry = new AppCLIEntry({ - configPath: CONFIG_PATH, + configPath: BASE_CONFIG, + overlayPath: WEB_OVERLAY, + ...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) }, dev, ...host !== undefined && { host }, ...port !== undefined && { port }, diff --git a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts b/apps/cli/tests/fixtures/tui-scripted-llm.ts similarity index 100% rename from examples/tui-agent/tests/fixtures/tui-scripted-llm.ts rename to apps/cli/tests/fixtures/tui-scripted-llm.ts diff --git a/apps/cli/tests/fixtures/tui-scripted.cordis.yml b/apps/cli/tests/fixtures/tui-scripted.cordis.yml new file mode 100644 index 0000000000..bde14e4627 --- /dev/null +++ b/apps/cli/tests/fixtures/tui-scripted.cordis.yml @@ -0,0 +1,71 @@ +# Overlay for the keyless conversational PTY test: the shipped composition with +# only the model replaced, so the terminal interaction is deterministic and +# network-free while the agent/TUI/user-question stack stays the production one. +# +# Passed as `--config`, so the launcher includes `base.cordis.yml`, applies +# `tui.cordis.yml`, then this file — all sibling patch lists at one include +# level. A patch replaces the targeted row's whole `config`, so each row below +# restates every key it owns. + +# The scripted adapter replaces the DeepSeek one: no key, no network. A patch's +# `name` is an assertion rather than a replacement, so the base row is disabled +# and the adapter inserted. Relative specifiers resolve against the INCLUDED +# file's directory (apps/cli), because the include moves baseUrl to that tree. +- id: llm-deepseek + disabled: true + +- insert: + - id: scripted-llm + name: './tests/fixtures/tui-scripted-llm.ts' + +- id: agent-loop + config: + agents: + - id: main + provider: tui-scripted + model: tui-scripted-model + # `cwd` scopes the session to this workspace, which is what `/resume` + # filters on; dropping it would hide the seeded session. + cwd: !!js process.cwd() + +- id: system-prompt + config: + persona: 'Scripted model {{model}}.' + +# The smoke's log inspection reads plain `.jsonl` under the workspace, so this +# fixture pins a project-local root instead of the launcher's shared store, and +# keeps the artifacts uncompressed like the other snapshot-facing configs. +- id: session-persistence-jsonl + config: + root: './.sessions' + compression: none + +# The derived index must sit under the same root as the logs it indexes; this +# fixture pins both to the workspace instead of the launcher's shared store. +- id: session-query-sqlite + config: + path: './.sessions/session-query.db' + +- id: plan-mode + config: + section: 'Stay in plan mode for this scripted TUI test.' + +# The scripted adapter answers the tool-less title request with a fixed string, +# so the PTY test can assert the logged title reaches the terminal window title. +- id: session-title-llm + config: + targetWords: 5 + targetCjkCharacters: 10 + maxInputBytes: 4096 + maxOutputTokens: 64 + timeoutMs: 10000 + +- id: tui + config: + sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main' + welcome: 'scripted TUI ready.' + showReasoning: true + +# HMR watches the repository; a PTY subprocess test must not start a watcher. +- id: hmr + disabled: true diff --git a/examples/tui-agent/tests/pty-harness.ts b/apps/cli/tests/pty-harness.ts similarity index 100% rename from examples/tui-agent/tests/pty-harness.ts rename to apps/cli/tests/pty-harness.ts diff --git a/examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl b/apps/cli/tests/snapshots/bash-terminal-card/session.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl rename to apps/cli/tests/snapshots/bash-terminal-card/session.jsonl diff --git a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt b/apps/cli/tests/snapshots/bash-terminal-card/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt rename to apps/cli/tests/snapshots/bash-terminal-card/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/session.jsonl b/apps/cli/tests/snapshots/code-mode-dispatch-spill/session.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/session.jsonl rename to apps/cli/tests/snapshots/code-mode-dispatch-spill/session.jsonl diff --git a/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt b/apps/cli/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt rename to apps/cli/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/code-mode/session.jsonl b/apps/cli/tests/snapshots/code-mode/session.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/code-mode/session.jsonl rename to apps/cli/tests/snapshots/code-mode/session.jsonl diff --git a/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt b/apps/cli/tests/snapshots/code-mode/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt rename to apps/cli/tests/snapshots/code-mode/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl b/apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl rename to apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl b/apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl rename to apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl b/apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl rename to apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.jsonl diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt b/apps/cli/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt rename to apps/cli/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl b/apps/cli/tests/snapshots/dynamic-workflow/session.1.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl rename to apps/cli/tests/snapshots/dynamic-workflow/session.1.jsonl diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl b/apps/cli/tests/snapshots/dynamic-workflow/session.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl rename to apps/cli/tests/snapshots/dynamic-workflow/session.jsonl diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt b/apps/cli/tests/snapshots/dynamic-workflow/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt rename to apps/cli/tests/snapshots/dynamic-workflow/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl b/apps/cli/tests/snapshots/multi-turn-conversation/session.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl rename to apps/cli/tests/snapshots/multi-turn-conversation/session.jsonl diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt b/apps/cli/tests/snapshots/multi-turn-conversation/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt rename to apps/cli/tests/snapshots/multi-turn-conversation/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/session.jsonl b/apps/cli/tests/snapshots/parallel-file-reads/session.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/parallel-file-reads/session.jsonl rename to apps/cli/tests/snapshots/parallel-file-reads/session.jsonl diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt b/apps/cli/tests/snapshots/parallel-file-reads/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt rename to apps/cli/tests/snapshots/parallel-file-reads/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/workspace/a.txt b/apps/cli/tests/snapshots/parallel-file-reads/workspace/a.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/parallel-file-reads/workspace/a.txt rename to apps/cli/tests/snapshots/parallel-file-reads/workspace/a.txt diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/workspace/b.txt b/apps/cli/tests/snapshots/parallel-file-reads/workspace/b.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/parallel-file-reads/workspace/b.txt rename to apps/cli/tests/snapshots/parallel-file-reads/workspace/b.txt diff --git a/examples/tui-agent/tests/snapshots/todo-plan/session.jsonl b/apps/cli/tests/snapshots/todo-plan/session.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/todo-plan/session.jsonl rename to apps/cli/tests/snapshots/todo-plan/session.jsonl diff --git a/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt b/apps/cli/tests/snapshots/todo-plan/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt rename to apps/cli/tests/snapshots/todo-plan/terminal.expected.txt diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts similarity index 89% rename from examples/tui-agent/tests/tui-keyless-smoke.e2e.ts rename to apps/cli/tests/tui-keyless-smoke.e2e.ts index 5b1ccdff75..289f716f6a 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -9,9 +9,10 @@ import { packChunkRuns, SessionId, type SessionEvent, type SessionHeader } from import { logPath, toHeaderLine } from '../../../packages/session-persistence/session-persistence-jsonl/src/format.ts' import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts' -const dshBinScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -const codeModeConfigPath = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url)) +const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) +// `--config` layers an overlay over the shared base, so the default surface +// needs no config argument at all; these are the overlays under test. +const codeModeConfigPath = fileURLToPath(new URL('../../../examples/code-mode/cordis.yml', import.meta.url)) const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) @@ -119,12 +120,16 @@ async function readLoggedRequestContext(cwd: string): Promise & { label: string }): Promise { return runTuiPtySmoke({ - tempDirPrefix: 'tui-agent-smoke-', + tempDirPrefix: 'dsh-tui-smoke-', binScript: dshBinScript, - configPath, tsconfigPath, env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call' }, ...overrides, @@ -139,13 +144,14 @@ const SELECT_PRO_MODEL = [ { waitFor: 'Select model', send: '\x1b[B\x1b[Z\r' }, ] as const -describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { +describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { it('boots pi-tui, sweeps the borderless banner in, enters plan mode, and restores the terminal', async () => { // With no configured welcome the borderless banner sweeps in left-to-right; // the detail line's session id (`main-session-`) renders only once // the sweep reaches it, so it marks a settled banner. const output = await smoke({ - label: 'tui-agent boot', + label: 'dsh boot', + configArgs: [], actions: [ { waitFor: 'main-session-', send: '/plan' }, { waitFor: '[off|message] — Enter or leave plan mode', send: '\r' }, @@ -165,8 +171,8 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => { const output = await smoke({ - label: 'tui-agent conversation', - tempDirPrefix: 'tui-agent-conversation-', + label: 'dsh conversation', + tempDirPrefix: 'dsh-tui-conversation-', configPath: scriptedConfigPath, actions: [ ...SELECT_PRO_MODEL, @@ -223,8 +229,8 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { // rendered `` block reaches the model — proven by the // scripted adapter echoing the fixture's body marker only when it arrives. const output = await smoke({ - label: 'tui-agent skill', - tempDirPrefix: 'tui-agent-skill-', + label: 'dsh skill', + tempDirPrefix: 'dsh-tui-skill-', configPath: scriptedConfigPath, prepare: seedWorkspace({ skills: { @@ -252,8 +258,11 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { it('fuzzy-completes an @file path without reading or submitting the file', async () => { const output = await smoke({ - label: 'tui-agent file autocomplete', - tempDirPrefix: 'tui-agent-file-autocomplete-', + label: 'dsh file autocomplete', + tempDirPrefix: 'dsh-tui-file-autocomplete-', + // The shipped composition: no welcome, so the banner's session-id detail + // line marks a settled boot. Completion never calls the model. + configArgs: [], prepare: seedWorkspace({ workspace: { 'src/terminal-special-case.ts': 'export const marker = true\n', @@ -275,8 +284,8 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { // The overlay's only keyless composition proof: the include+patch tree, // worker code runtime, and one-tool registry all mount before the banner. const output = await smoke({ - label: 'tui-agent code mode', - tempDirPrefix: 'tui-agent-code-mode-', + label: 'dsh code mode', + tempDirPrefix: 'dsh-tui-code-mode-', configPath: codeModeConfigPath, actions: [{ waitFor: 'TUI Code Mode ready.', send: '/exit\r' }], }) @@ -325,8 +334,10 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { it('applies the personal overlay: config.yaml patches the tree and .env feeds its !!js', async () => { // The whole personal-config chain in one boot: the personal .env supplies - // the variable, config.yaml patches the tui-agent entry with a `!!js` - // reference to it, and the banner renders the patched welcome verbatim. + // the variable, config.yaml patches the `tui` row — a row the SURFACE + // OVERLAY inserted, not one the base declares — with a `!!js` reference to + // it, and the banner renders the patched welcome verbatim. That proves a + // later patch list reaches a row an earlier one inserted. const output = await smoke({ label: 'dsh personal overlay', tempDirPrefix: 'dsh-personal-overlay-', @@ -336,12 +347,11 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { personal: { '.env': 'DSH_PERSONAL_WELCOME=PERSONAL OVERLAY READY.\n', 'config.yaml': [ - '- id: tui-agent', - " name: '@deepseek-ai/dsh-tui-demo'", + '- id: workspace-context', + ' disabled: true', + '- id: tui', ' config:', - ' provider: deepseek', - ' model: deepseek-v4-flash', - ' workspaceContext: false', + " sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main'", ' welcome: !!js process.env.DSH_PERSONAL_WELCOME', '', ].join('\n'), @@ -393,10 +403,11 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36} --config/) }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('keeps resume working when the personal overlay replaces the whole tui-agent config', async () => { + it('keeps resume working when the personal overlay replaces the whole agent-loop config', async () => { // Loader patches replace a targeted `config` key wholesale, so a personal - // overlay that omits a resume key used to silently disable the exit hint. - // Launcher-owned identity and exit line make that unreachable. + // overlay repointing the model route drops every identity key the shipped + // row declared. Launcher-owned identity makes that unreachable: agent-loop + // applies the launcher's id over whatever route survives. const output = await smoke({ label: 'dsh overlay keeps resume', tempDirPrefix: 'dsh-overlay-resume-', @@ -405,12 +416,18 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { prepare: seedWorkspace({ personal: { 'config.yaml': [ - '- id: tui-agent', - " name: '@deepseek-ai/dsh-tui-demo'", + '- id: workspace-context', + ' disabled: true', + '- id: agent-loop', ' config:', - ' provider: deepseek', - ' model: deepseek-v4-flash', - ' workspaceContext: false', + ' agents:', + ' - id: main', + ' provider: deepseek', + ' model: deepseek-v4-flash', + ' cwd: !!js process.cwd()', + '- id: tui', + ' config:', + " sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main'", ' welcome: OVERLAY REPLACED THE CONFIG.', '', ].join('\n'), @@ -426,7 +443,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { // consumes into its own `[exit 3]` pill. Rendering both would report the same // exit twice, so the marker must not survive into the card body. const output = await smoke({ - label: 'tui-agent bash exit pill', + label: 'dsh bash exit pill', tempDirPrefix: 'dsh-bash-exit-pill-', configPath: scriptedConfigPath, actions: [ diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/apps/cli/tests/tui.snapshot.ts similarity index 100% rename from examples/tui-agent/tests/tui.snapshot.ts rename to apps/cli/tests/tui.snapshot.ts diff --git a/apps/cli/tui.cordis.yml b/apps/cli/tui.cordis.yml new file mode 100644 index 0000000000..0a6f2213c1 --- /dev/null +++ b/apps/cli/tui.cordis.yml @@ -0,0 +1,131 @@ +# `dsh` (the default surface) — the full-screen TUI, as a patch list over +# `base.cordis.yml`. The launcher includes the base and applies this file, then +# any `--config` overlay, then the personal `~/.dsh/config.yaml`, as sibling +# patch lists at ONE include level: patches never cross an include boundary, so +# stacking overlays as nested includes would silently stop reaching base rows. +# +# A patch replaces the targeted row's whole `config`, so each row below restates +# every key it owns. A patch whose `id` matches no row is skipped with a Loader +# warning, which is deliberate: one personal overlay is shared across surfaces, +# so a row that exists only under `web` must not fail the TUI's boot. +# +# The launcher owns session identity and the exit line, and provides both on the +# boot context rather than through config, so no key here — and no overlay +# replacing one — can drop `--resume`. + +# ── surface-specific values the base deliberately omits ───────────────────── + +# `main` is the agent the TUI drives. `provider`/`model` are the route `dsh +# login` rewrites and a personal overlay repoints; `cwd` anchors the session to +# the invoking directory, which is also what scopes `/resume` to this workspace. +- id: agent-loop + config: + agents: + - id: main + provider: deepseek + model: deepseek-v4-pro + cwd: !!js process.cwd() + +# Keep the persona to identity and behavior; tool plugins own tool guidance. +# The loop resolves {{model}} from this agent's configuration. +- id: system-prompt + config: + persona: | + You are a coding agent powered by the {{model}} model. + + Verify your work by running the code or tests. Keep answers brief and + factual. + +# Shipped default: full thinking at max effort on every request (wire-only +# defaults; they never enter the request header). +- id: llm-deepseek + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + thinking: enabled + reasoningEffort: max + +# This single-session app resolves relative paths from the process cwd. +- id: fs-local + config: + cwd: !!js process.cwd() + +# The shipped TUI presents the native tool registry. `examples/code-mode` is the +# overlay that switches this row to the `run_code` transport. +- id: tools + config: + mode: native + +# ── TUI-only rows ─────────────────────────────────────────────────────────── + +- insert: + # Development-only hot reload; it depends on Loader internals, so it stays a + # surface row rather than joining the shared base. + - id: hmr + name: '@cordisjs/plugin-hmr' + config: + root: ['.'] + + # Relational runtime checks over the authoritative event streams; each + # companion registers the assertions its own package owns. + - id: invariants + name: '@deepseek-ai/dsh-invariants' + - id: session-invariant + name: '@deepseek-ai/dsh-session/invariant' + - id: agent-invariant + name: '@deepseek-ai/dsh-agent/invariant' + - id: scope-invariant + name: '@deepseek-ai/dsh-scope/invariant' + - id: agent-loop-invariant + name: '@deepseek-ai/dsh-agent-loop/invariant' + + - id: session-checkpoint-policy + name: '@deepseek-ai/dsh-session-checkpoint-policy' + + # The derived query index behind `/resume`. The launcher owns the session + # store location, so it provides the resolved index path on the boot context + # (`launcherSessionQueryPath`); the index and the logs it indexes therefore + # cannot diverge. The project-local fallback applies when no launcher sets it. + - id: session-query-sqlite + name: '@deepseek-ai/dsh-session-query-sqlite' + config: + path: !!js launcherSessionQueryPath ?? './.sessions/session-query.db' + + - id: session-reference + name: '@deepseek-ai/dsh-session-reference' + + # Refuses write/edit inside the dsh checkout this launcher runs from, on that + # checkout's own branch, until the session loads dsh-customize. Inert + # everywhere else, so an ordinary project sees no change. + - id: source-guard + name: '@deepseek-ai/dsh-source-guard' + + - id: tool-result-prune + name: '@deepseek-ai/dsh-compact-tool-result-prune' + + # Persisted same-session goals reach the model and the slash menu here; the + # domain, driver, and `/goal` command are in the base. + - id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + + # A separate fixed consumer demonstrates fresh-agent Ralph iteration without + # changing the workflow tool or same-session goal behavior. + - id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + + # The keyboard-backed provider behind ask_user_question and the plan-mode + # review, and the front door it renders inside. + - id: tui-prompt + name: '@deepseek-ai/dsh-tui/prompt' + + # The TUI renders exactly the agent the agent-loop row bound, so it reads the + # same launcher-owned identity rather than restating one. + - id: tui + name: '@deepseek-ai/dsh-tui' + config: + sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main' + showReasoning: true + maxToolOutputLines: 6 + + - id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' diff --git a/apps/cli/web.cordis.yml b/apps/cli/web.cordis.yml new file mode 100644 index 0000000000..9cbd73a6f6 --- /dev/null +++ b/apps/cli/web.cordis.yml @@ -0,0 +1,167 @@ +# `dsh web` — the browser surface, as a patch list over `base.cordis.yml`. +# The launcher includes the base and applies this file, then any `--config` +# overlay, then AppCLIEntry's profile-json and CLI-flag patches, as sibling patch +# lists at ONE include level: patches never cross an include boundary, so +# stacking overlays as nested includes would silently stop reaching base rows. +# +# A patch replaces the targeted row's whole `config`, so each row below restates +# every key it owns. `--dev` appends the dsh-client-hmr row in code +# (AppCLIEntry) — prod and dev differ by exactly that one row. + +# ── surface-specific values the base deliberately omits ───────────────────── + +- id: system-prompt + config: + persona: '' + +- id: tools + config: + # TEMPORARY workaround: DSH_TOOLS_MODE (native|code|both) opts a whole dsh + # process into Code Mode while per-session tool-mode selection is being + # designed; unset keeps the schema default (native). Remove the env seam + # once the web UI owns the choice per session. + mode: !!js process.env.DSH_TOOLS_MODE + +- id: agent-loop + config: + agents: [] + +- id: llm-deepseek + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + +- id: fs-local + +# ── web-only host rows, the transport layer, and the browser roster ───────── + +# `dshClient` rows are the browser roster the modules node half scans into +# window.__DSH_BOOT__; the modules row is simultaneously a host row. +- insert: + - id: session-projection + name: '@deepseek-ai/dsh-session-projection' + + - id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' + + - id: storage + name: '@deepseek-ai/dsh-storage' + + - id: storage-json + name: '@deepseek-ai/dsh-storage-json' + config: + root: './.storages' + + - id: storage-domain + name: '@deepseek-ai/dsh-storage-domain' + config: + backend: json + + - id: workspace + name: '@deepseek-ai/dsh-workspace' + + - id: session-projection-cache + name: '@deepseek-ai/dsh-session-projection-cache' + config: + writeEveryEvents: 200 + writeIntervalMs: 5000 + + - id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + + # The API gateway: the transport-agnostic dispatch face every client shape + # shares. provider/model are the host default routing — the profile json's + # mapping target (user config overrides these engineering defaults). + - id: api-gateway + name: '@deepseek-ai/dsh-host-apiproxy' + config: + provider: deepseek + model: deepseek-v4-flash + + # ── layer 2: transport/service ────────────────────────────────────────────── + + # Plain route-registration carrier. distIndex is an assembly fact, not user + # config — AppCLIEntry resolves the frontend dist and patches it in; host and + # port arrive as CLI-flag patches over these defaults. + - id: webserver + name: '@deepseek-ai/dsh-host-webserver' + config: + host: 127.0.0.1 + port: 3080 + + # ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ── + + # Dual-face: node half scans this very tree for dshClient rows, composes + # window.__DSH_BOOT__, serves /plugins//client.js; browser half is the + # module table the shell kernel constructs before cordis exists (§4.7 — + # adopted as a plugin entry by the kernel, never fetched). + - id: modules + name: '@deepseek-ai/dsh-client-modules' + + # Owns both ends of the web transport: node half binds the gateway to the + # webserver under /api; browser half is the fetch/SSE client. + - id: connection + name: '@deepseek-ai/dsh-client-connection' + + - id: client-runtime + name: '@deepseek-ai/dsh-client-runtime' + + - id: ui-theme + name: '@deepseek-ai/dsh-client-ui-theme' + + - id: locale + name: '@deepseek-ai/dsh-client-locale' + + - id: ui-layout + name: '@deepseek-ai/dsh-client-ui-layout' + + - id: ui-sidebar + name: '@deepseek-ai/dsh-client-ui-sidebar' + + - id: ui-settings + name: '@deepseek-ai/dsh-client-ui-settings' + + - id: ui-settings-general + name: '@deepseek-ai/dsh-client-ui-settings-general' + + - id: ui-models + name: '@deepseek-ai/dsh-client-ui-models' + + - id: ui-conversation + name: '@deepseek-ai/dsh-client-ui-conversation' + + + - id: ui-workspace + name: '@deepseek-ai/dsh-client-ui-workspace' + + # Input triggers: the '/' | '@' pipeline (ui-slash), the command surface over + # it (ui-command), and the two reference sources (ui-skill / ui-subagent). + - id: ui-slash + name: '@deepseek-ai/dsh-client-ui-slash' + + - id: ui-command + name: '@deepseek-ai/dsh-client-ui-command' + + - id: ui-skill + name: '@deepseek-ai/dsh-client-ui-skill' + + - id: ui-subagent + name: '@deepseek-ai/dsh-client-ui-subagent' + + # Goal surface: GoalBar in the input dock over the goal session projection. + - id: ui-goal + name: '@deepseek-ai/dsh-client-ui-goal' + + # Model selection: the /model popupSelect + composer seat over session.models. + - id: ui-model + name: '@deepseek-ai/dsh-client-ui-model' + + # Plan control: the composer plan seat over the plan projection + /plan channel. + - id: ui-plan + name: '@deepseek-ai/dsh-client-ui-plan' + + - id: ui-question + name: '@deepseek-ai/dsh-client-ui-question' + + - id: ui-trajectory + name: '@deepseek-ai/dsh-client-ui-trajectory' diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 6de0f7ff6e..f4607ce0f2 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -89,7 +89,6 @@ flowchart LR pkg_skill_local["skill-local"] svc_agents["ctx.agents
Agent service"] pkg_acp["acp"] - pkg_tui_demo["tui-demo"] svc_agentLoop["ctx.agentLoop
Concrete loop driver"] pkg_agent_spine_demo["agent-spine-demo"] pkg_goal["goal"] @@ -245,7 +244,6 @@ flowchart LR svc_agents --> pkg_agent_loop svc_agents --> pkg_cli_demo svc_agents --> pkg_subagent_inprocess - svc_agents --> pkg_tui_demo svc_approval --> pkg_tool_bash svc_approval --> pkg_tools svc_bash --> pkg_hooks_claude @@ -359,7 +357,7 @@ flowchart LR | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | | `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/acp/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.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | 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. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp) | - | The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8bd6e966b9..35280954e7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -108,7 +108,7 @@ export interface Config { Depends on: [`AgentOptions`](core-data-structures/core.md) · [`SessionId`](core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:155`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:211`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-agent-spine-demo` @@ -2005,59 +2005,6 @@ export interface TuiThemeConfig { Source: [`packages/ui/tui/src/config.ts:117`](../packages/ui/tui/src/config.ts) -## `@deepseek-ai/dsh-tui-demo` - -```ts config-catalog -/** 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 - /** - * Directory for JSONL sessions and the derived query index. Precedence: - * this explicit config, then the launcher's opaque `SESSIONS_ROOT_KEY` boot - * slot (the dsh CLI resolves it to `DSH_HOME/sessions`), then a project-local - * `./.sessions` fallback — the bundle itself never assumes a global store. - */ - 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 - /** 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 - /** Generic background-task controls forwarded through agent-spine-demo; set false to omit them. */ - toolTasks?: NonNullable - /** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */ - goals?: agentCore.GoalConfig | false - /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ - workspaceContext: agentCore.Config['workspaceContext'] -} -``` - -Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`SessionReferenceConfig`](#deepseek-aidsh-session-reference) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) - -Source: [`packages/examples/tui-demo/src/index.ts:44`](../packages/examples/tui-demo/src/index.ts) - ## `@deepseek-ai/dsh-user-approval` ```ts config-catalog diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 9ff53f33c3..b971e0ce51 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/extension-cookbook.md -extension-cookbook.md: 36ab56dcdce1166ef69cec7834f6c17be72d89c3 -extension-cookbook.zh.md: 8c8f9486ec592fcc80f1053f54adce2e33798d4b +extension-cookbook.md: 3fed79372e284df8d2cf87a3b1ca6fac6e2131ae +extension-cookbook.zh.md: 1d422712592c7427fde785f1419633a13a55cca3 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 36ab56dcdc..3fed79372e 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -91,7 +91,7 @@ export function apply(ctx: Context) { ## Runnable wirings -Runnable leaves load their plugin trees from `examples/*/cordis.yml`; the root `demo:*` scripts and those leaf directories are the authoritative inventory. Interactive leaves use [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo), non-interactive leaves use [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo), ACP leaves use [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and the app packages share [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo). +Runnable leaves load their plugin trees from `examples/*/cordis.yml`; the root `demo:*` scripts and those leaf directories are the authoritative inventory. Interactive leaves use [`@deepseek-ai/dsh-tui`](../../packages/ui/tui), non-interactive leaves use [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo), ACP leaves use [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and the app packages share [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo). ## The feature → mechanism map diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 8c8f9486ec..1d42271259 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -91,7 +91,7 @@ export function apply(ctx: Context) { ## 可运行的组装示例 -可运行叶子从 `examples/*/cordis.yml` 加载各自的插件树;根目录的 `demo:*` 脚本和这些叶子目录是权威清单。交互式叶子使用 [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo),非交互式叶子使用 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo),ACP 叶子使用 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),应用包共享 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo)。 +可运行叶子从 `examples/*/cordis.yml` 加载各自的插件树;根目录的 `demo:*` 脚本和这些叶子目录是权威清单。交互式叶子使用 [`@deepseek-ai/dsh-tui`](../../packages/ui/tui),非交互式叶子使用 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo),ACP 叶子使用 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),应用包共享 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo)。 ## 功能→机制映射 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 967241fbf6..93b87fe9d9 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -381,7 +381,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers Types: [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:148`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:157`](../../packages/core/agent-loop/src/index.ts) ## `approval/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 7010113be7..f576af1243 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -44,7 +44,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise pkg_session_persistence_jsonl pkg_cli_demo --> pkg_tools pkg_cli_demo --> pkg_workspace_context - pkg_tui_demo --> pkg_agent - pkg_tui_demo --> pkg_agent_loop - pkg_tui_demo --> pkg_agent_spine_demo - pkg_tui_demo --> pkg_command_goal - pkg_tui_demo --> pkg_commands - pkg_tui_demo --> pkg_invariants - pkg_tui_demo --> pkg_llm - pkg_tui_demo --> pkg_session - pkg_tui_demo --> pkg_session_checkpoint_policy - pkg_tui_demo --> pkg_session_persistence_jsonl - pkg_tui_demo --> pkg_session_query - pkg_tui_demo --> pkg_session_query_sqlite - pkg_tui_demo --> pkg_session_reference - pkg_tui_demo --> pkg_tool_ask_user - pkg_tui_demo --> pkg_tools - pkg_tui_demo --> pkg_tui - pkg_tui_demo --> pkg_user_interaction - pkg_tui_demo --> pkg_workspace_context pkg_sdk_client --> pkg_invariants pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol @@ -1160,6 +1141,5 @@ flowchart TD | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`session-reference`](../packages/context/session-reference), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index 976f52c779..b8397d1b8e 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/testing.md -testing.md: 04bd7782fa4328b6b693f13f60f4e33b463f8a18 -testing.zh.md: 5712fd8ce7b0cd46ebeb237bdd12c6c572ebe3de +testing.md: 53f6d3c8a9c0eca61d7c5272ab78da9492ddc2a2 +testing.zh.md: 527ab0b1a0fa28fc9fd210e7955aa2280cd1850a diff --git a/docs/testing.md b/docs/testing.md index 04bd7782fa..53f6d3c8a9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -46,4 +46,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## When a snapshot test is required -Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `examples/tui-agent/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. +Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `apps/cli/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index 5712fd8ce7..527ab0b1a0 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -46,4 +46,4 @@ e2e 断言应重新运行命令或从外部重新读取文件;对 agent 自身 ## 何时需要快照测试 -每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples//tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有 `stream-json` 快照与回放 fixture。已完成的交互式终端旅程使用 `examples/tui-agent/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,输入、Loader 选择或终端清理发生变化时还要添加 PTY 用例。新的能力 seam、生命周期形态或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 +每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples//tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有 `stream-json` 快照与回放 fixture。已完成的交互式终端旅程使用 `apps/cli/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,输入、Loader 选择或终端清理发生变化时还要添加 PTY 用例。新的能力 seam、生命周期形态或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 995a8c669d..399d0aff27 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -19,7 +19,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | -| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | +| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | @@ -28,7 +28,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | -| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | +| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `apps/cli/base.cordis.yml` and `examples/acp-agent/cordis.yml`. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflows`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | @@ -277,7 +277,7 @@ Unmount a current-process temporary Plugin created by cordis_mount. Waits for it Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cordis/src/index.ts) -Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. +Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. ## `@deepseek-ai/dsh-tool-fs` @@ -1049,7 +1049,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its Source: [`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. +The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `apps/cli/base.cordis.yml` and `examples/acp-agent/cordis.yml`. ## `@deepseek-ai/dsh-tool-tasks` diff --git a/docs/user/develop/practice/llm-adapter.i18n.yaml b/docs/user/develop/practice/llm-adapter.i18n.yaml index 402e3537a7..0044ac720e 100644 --- a/docs/user/develop/practice/llm-adapter.i18n.yaml +++ b/docs/user/develop/practice/llm-adapter.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -llm-adapter.md: 3bf005647d3e7908f1d1553374266c1d6c12c2e6 -llm-adapter.zh.md: b967d112ed6879b11486ab7aedf64653089d93de +# pnpm run verify-translation-pairing --write docs/user/develop/practice/llm-adapter.md +llm-adapter.md: 7445688530c1ba61e5c065f9f5e49db6498da5b1 +llm-adapter.zh.md: c30200314a01f7a61d48e3f47288013c31e5aef4 diff --git a/docs/user/develop/practice/llm-adapter.md b/docs/user/develop/practice/llm-adapter.md index 3bf005647d..7445688530 100644 --- a/docs/user/develop/practice/llm-adapter.md +++ b/docs/user/develop/practice/llm-adapter.md @@ -133,10 +133,12 @@ The first argument lists the model names handled by the adapter. If `cordis.yml` - my-model-v1 - my-model-v2 -- id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' config: - provider: my-llm + agents: + - id: main + provider: my-llm model: my-model-v1 # References the model registered above. workspaceContext: false ``` diff --git a/docs/user/develop/practice/llm-adapter.zh.md b/docs/user/develop/practice/llm-adapter.zh.md index b967d112ed..c30200314a 100644 --- a/docs/user/develop/practice/llm-adapter.zh.md +++ b/docs/user/develop/practice/llm-adapter.zh.md @@ -133,10 +133,12 @@ ctx.llm.registerAdapter(['model-name-1', 'model-name-2'], adapter) - my-model-v1 - my-model-v2 -- id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' config: - provider: my-llm + agents: + - id: main + provider: my-llm model: my-model-v1 # References the model registered above. workspaceContext: false ``` diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml index 695584a6e6..a45fbb2186 100644 --- a/docs/user/guide/config.i18n.yaml +++ b/docs/user/guide/config.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -config.md: a884cb9c2ec31bd4b12a31cce6290df1d134cf9a -config.zh.md: 3fb9ce69e5f7cb6b92f055ef18595e5ff07d9bbf +# pnpm run verify-translation-pairing --write docs/user/guide/config.md +config.md: c9c760cbdb9650e608809fee4498b7f884b339dc +config.zh.md: b80e31c6f77f51d013aaebe5c4e9e43f5819e33f diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md index a884cb9c2e..c9c760cbdb 100644 --- a/docs/user/guide/config.md +++ b/docs/user/guide/config.md @@ -8,7 +8,7 @@ Harness uses `cordis.yml` to describe which plugins an agent loads and the confi The repository examples are runnable configurations and the most reliable starting points for a new project: -- [tui-agent](../../../examples/tui-agent/cordis.yml) combines the DeepSeek model, Bash, filesystem, compaction, subagents, workflows, and the interactive TUI. +- [the shared `dsh` base](../../../apps/cli/base.cordis.yml) plus the [`tui.cordis.yml`](../../../apps/cli/tui.cordis.yml) overlay combines the DeepSeek model, Bash, filesystem, compaction, subagents, workflows, and the interactive TUI. - [headless-agent](../../../examples/headless-agent/cordis.yml) exposes the coding composition as a one-shot task. - [acp-agent](../../../examples/acp-agent/cordis.yml) exposes fresh sessions to programmatic ACP clients. @@ -25,12 +25,13 @@ A minimal configuration is a list of plugin entries: - id: bash name: '@deepseek-ai/dsh-bash-local' -- id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' config: - provider: deepseek - model: deepseek-v4-flash - workspaceContext: false + agents: + - id: main + provider: deepseek + model: deepseek-v4-flash ``` ## Plugin entries diff --git a/docs/user/guide/config.zh.md b/docs/user/guide/config.zh.md index 3fb9ce69e5..b80e31c6f7 100644 --- a/docs/user/guide/config.zh.md +++ b/docs/user/guide/config.zh.md @@ -8,7 +8,7 @@ Harness 使用 `cordis.yml` 描述 Agent 加载哪些插件以及每个插件的 仓库中的示例就是可以运行的配置,也是新项目最可靠的起点: -- [tui-agent](../../../examples/tui-agent/cordis.yml) 组合 DeepSeek 模型、Bash、文件系统、压缩、子代理、工作流和交互式 TUI。 +- [共享的 `dsh` base](../../../apps/cli/base.cordis.yml) 叠加 [`tui.cordis.yml`](../../../apps/cli/tui.cordis.yml) overlay,组合 DeepSeek 模型、Bash、文件系统、压缩、子代理、工作流和交互式 TUI。 - [headless-agent](../../../examples/headless-agent/cordis.yml) 以单次任务形式暴露 coding 组装。 - [acp-agent](../../../examples/acp-agent/cordis.yml) 向程序化 ACP(Agent Client Protocol)客户端提供全新会话。 @@ -25,12 +25,13 @@ Harness 使用 `cordis.yml` 描述 Agent 加载哪些插件以及每个插件的 - id: bash name: '@deepseek-ai/dsh-bash-local' -- id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' config: - provider: deepseek - model: deepseek-v4-flash - workspaceContext: false + agents: + - id: main + provider: deepseek + model: deepseek-v4-flash ``` ## 插件条目 diff --git a/docs/user/guide/index.i18n.yaml b/docs/user/guide/index.i18n.yaml index e2b307201e..17056741b7 100644 --- a/docs/user/guide/index.i18n.yaml +++ b/docs/user/guide/index.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -index.md: b698b8aeee6cebff374e20ca0f76ddc9e75213c0 -index.zh.md: 337d246baa12ccf6d7a9656d1ea3b06002554c13 +# pnpm run verify-translation-pairing --write docs/user/guide/index.md +index.md: 72d822f6377089f56abda4383f393eed30a6709a +index.zh.md: 442cda48072c3f6bf9579e880c0ab381b799966f diff --git a/docs/user/guide/index.md b/docs/user/guide/index.md index b698b8aeee..72d822f637 100644 --- a/docs/user/guide/index.md +++ b/docs/user/guide/index.md @@ -14,12 +14,17 @@ Harness implements every capability an AI agent needs—including LLM calls, too config: apiKey: !!js process.env.DEEPSEEK_API_KEY -# Select the interactive application -- name: '@deepseek-ai/dsh-tui-demo' +# Select the agent the interactive front door drives +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' config: - provider: deepseek - model: deepseek-v4-flash - workspaceContext: false + agents: + - id: main + provider: deepseek + model: deepseek-v4-flash + +# Select the interactive front door +- name: '@deepseek-ai/dsh-tui' ``` ## Who it is for diff --git a/docs/user/guide/index.zh.md b/docs/user/guide/index.zh.md index 337d246baa..442cda4807 100644 --- a/docs/user/guide/index.zh.md +++ b/docs/user/guide/index.zh.md @@ -14,12 +14,17 @@ Harness 将一个 AI Agent(智能体) 所需要的所有能力——LLM 调 config: apiKey: !!js process.env.DEEPSEEK_API_KEY -# Select the interactive application -- name: '@deepseek-ai/dsh-tui-demo' +# Select the agent the interactive front door drives +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' config: - provider: deepseek - model: deepseek-v4-flash - workspaceContext: false + agents: + - id: main + provider: deepseek + model: deepseek-v4-flash + +# Select the interactive front door +- name: '@deepseek-ai/dsh-tui' ``` ## 适合谁 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index b3de74949b..71065d0781 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -quickstart.md: 25ce51ee3d010d2eb800071b9697fc62857dace1 -quickstart.zh.md: e2e023670a999566e273d8893c42103dc273e7b1 +# pnpm run verify-translation-pairing --write docs/user/guide/quickstart.md +quickstart.md: 75c0d7096f09092106aca46a5a078673708040e7 +quickstart.zh.md: 0beadb65f32f21d18bb4550eda967ed2b275b10f diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index 25ce51ee3d..be41d00d0f 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -52,7 +52,7 @@ The full-screen agent can read and write files, run commands, delegate subtasks, ## What happened -headless-agent uses the `@deepseek-ai/dsh-cli-demo` app; tui-agent uses the interactive `@deepseek-ai/dsh-tui-demo` app. Both load the same providerless agent spine, while their `cordis.yml` files select the DeepSeek model and capability plugins appropriate to each surface. +headless-agent uses the `@deepseek-ai/dsh-cli-demo` app; the interactive `dsh` surface instead composes [`apps/cli/base.cordis.yml`](../../../apps/cli/base.cordis.yml) with the `tui.cordis.yml` overlay and no app bundle. Both load the same providerless agent spine, while their `cordis.yml` files select the DeepSeek model and capability plugins appropriate to each surface. ## Next steps diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index e2e023670a..f592fdb3fb 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -52,7 +52,7 @@ pnpm run demo:tui ## 回头看 -headless-agent 使用 `@deepseek-ai/dsh-cli-demo` app,tui-agent 使用交互式 `@deepseek-ai/dsh-tui-demo` app。二者加载同一个 providerless agent spine,并通过各自的 `cordis.yml` 为对应 surface 选择 DeepSeek 模型和能力插件。 +headless-agent 使用 `@deepseek-ai/dsh-cli-demo` app;交互式 `dsh` surface 则以 [`apps/cli/base.cordis.yml`](../../../apps/cli/base.cordis.yml) 叠加 `tui.cordis.yml` overlay 组合而成,不使用 app 组合包。二者加载同一个 providerless agent spine,并通过各自的 `cordis.yml` 为对应 surface 选择 DeepSeek 模型和能力插件。 ## 下一步 diff --git a/examples/README.i18n.yaml b/examples/README.i18n.yaml index 647d829b4f..a05f495338 100644 --- a/examples/README.i18n.yaml +++ b/examples/README.i18n.yaml @@ -2,5 +2,10 @@ # 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 examples/README.md +<<<<<<< HEAD README.md: 7f12178d1b67f1ebfac6f4f0e31403c54106e98f README.zh.md: c7c1bf76593661616464558e554d57340d7c03b1 +======= +README.md: eb425ad152579ee10bbd54e99c667606fc8a649a +README.zh.md: 5f00b4471b516bb78679f09b41dcb1bdbc3ebdbc +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/examples/README.md b/examples/README.md index 7f12178d1b..eb425ad152 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: a `cordis.yml` that picks swappable backends, loads one app package, and may add optional product tools. The composition and boot glue live in [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo), [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo), and their shared [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle. There is no `start.ts`; the terminal `demo:*` scripts boot through the [`dsh`](../apps/cli/README.md) CLI (which mounts the `tui-demo` bundle), and the headless/ACP scripts invoke the `cli-demo`/`acp-demo` bins. +Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: either a `cordis.yml` tree that picks swappable backends and loads one app package, or an **overlay** — a patch list `dsh --config` applies over the shipped composition ([`apps/cli/base.cordis.yml`](../apps/cli/base.cordis.yml) plus a surface overlay). Bundled compositions live in [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo), and their shared [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle; the `dsh` surfaces use flat config trees instead. There is no `start.ts`; the terminal `demo:*` scripts boot through the [`dsh`](../apps/cli/README.md) CLI, and the headless/ACP scripts invoke the `cli-demo`/`acp-demo` bins. ## headless-agent @@ -10,21 +10,21 @@ A non-interactive agent demo that accepts one positional task, runs one complete Run with: `pnpm run demo:headless "task"` (needs `DEEPSEEK_API_KEY`). See [headless-agent/README.md](headless-agent/README.md) for the output contract, safety boundaries, and snapshot suite. -## tui-agent +## code-mode -The interactive coding agent: DeepSeek V4, filesystem and bash tools, subagents, workflows, `todo_write`, compaction, and the full-screen TUI. It is also the home of TUI PTY and snapshot scenarios. +An **overlay** over the shipped TUI that reduces the model-facing registry to the `run_code` transport, so the model batches tool work into TypeScript programs instead of one call per turn. -Run with: `pnpm run demo:tui` (needs `DEEPSEEK_API_KEY`). Run its Code Mode overlay with `pnpm run demo:code-mode`. See [tui-agent/README.md](tui-agent/README.md) for controls and composition. +Run with: `pnpm run demo:code-mode` (needs `DEEPSEEK_API_KEY`). See [code-mode/README.md](code-mode/README.md). The interactive agent itself is not an example: `pnpm run demo:tui` boots [`apps/cli/tui.cordis.yml`](../apps/cli/tui.cordis.yml) over the shared base, and its PTY and snapshot scenarios live in `apps/cli/tests/`. ## jsonrpc-agent An unattended coding agent driven through the Python SDK: JSON-RPC stdio, foreground-only `bash`, `read` / `write` / `edit`, one foreground `subagent`, `todo_write`, JSONL persistence, and compaction. It excludes terminal UI, stdout logging, approvals, skills, and background task controls. See [jsonrpc-agent/README.md](jsonrpc-agent/README.md). -## cordis-agent +## web-cordis The **self-referential** demo: the coding spine plus [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis), whose three tools (`cordis_inspect` / `cordis_mount` / `cordis_unmount`) let the agent inspect the current DSH process, mount model-written temporary Plugins (an event listener, a brand-new tool, or a service another temporary Plugin injects), and unmount them again. These Plugins exist only in memory and share one internal `cordis-dynamic` fiber subtree; `ctx.fs`/`ctx.web` ride along provider-only as capabilities they can use. -Run the TUI with `pnpm run demo:cordis`, the browser UI at `http://127.0.0.1:3081` with `pnpm run demo:cordis web`, or the ACP server with `pnpm run demo:cordis acp` (all need `DEEPSEEK_API_KEY`). See [cordis-agent/README.md](cordis-agent/README.md) for the staged demo script and [the toolset Agent Note](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md) for the design and sandbox caveats. +Run the browser UI at `http://127.0.0.1:3081` with `pnpm run demo:cordis`, or the ACP server with `pnpm run demo:cordis acp` (both need `DEEPSEEK_API_KEY`). See [the toolset Agent Note](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md) for the design and sandbox caveats. ## acp-agent diff --git a/examples/README.zh.md b/examples/README.zh.md index c7c1bf7659..de91e30217 100644 --- a/examples/README.zh.md +++ b/examples/README.zh.md @@ -2,7 +2,11 @@ [English](README.md) | 中文 +<<<<<<< HEAD 展示 harness 如何组装的可运行演示(不是 workspace)。每个示例都是一个 **轻量叶节点**:一份选择可替换后端、加载一个应用包(package)并可添加可选产品工具的 `cordis.yml`。组合和启动粘合代码位于 [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo)、[`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo)、[`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 及它们共享的 [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) 组合包中。没有 `start.ts`;终端 `demo:*` 脚本通过 [`dsh`](../apps/cli/README.md) CLI(命令行界面)启动(该 CLI 挂载 `tui-demo` 组合包),无头/ACP(Agent Client Protocol)脚本则调用 `cli-demo`/`acp-demo` bin。 +======= +展示 harness 如何接线的可运行演示(不是 workspace)。每个示例都是一个 **轻量叶节点**:要么是一份选择可替换后端、加载一个应用包(package)的 `cordis.yml` 配置树,要么是一个 **overlay**——由 `dsh --config` 叠加到交付组合([`apps/cli/base.cordis.yml`](../apps/cli/base.cordis.yml) 加一份 surface overlay)之上的 patch 列表。成组的组合位于 [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo)、[`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 及它们共享的 [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) 组合包中;`dsh` 的各 surface 则改用平铺 config tree。没有 `start.ts`;终端 `demo:*` 脚本通过 [`dsh`](../apps/cli/README.md) CLI(命令行界面)启动,无头/ACP(Agent Client Protocol)脚本则调用 `cli-demo`/`acp-demo` bin。 +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) ## headless-agent @@ -10,21 +14,25 @@ 运行:`pnpm run demo:headless "task"`(需要 `DEEPSEEK_API_KEY`)。输出契约、安全边界和快照套件详见 [headless-agent/README.md](headless-agent/README.md)。 -## tui-agent +## code-mode -交互式编码 agent:DeepSeek V4、文件系统与 bash 工具、subagent、工作流、`todo_write`、压缩(compaction)和全屏 TUI。这里也是 TUI PTY 与快照场景的归属地。 +叠加在交付 TUI 之上的 **overlay**:把面向模型的注册表收敛为 `run_code` 这一个传输,使模型把工具工作批量写进 TypeScript 程序,而不是每轮一次调用。 -运行:`pnpm run demo:tui`(需要 `DEEPSEEK_API_KEY`)。使用 `pnpm run demo:code-mode` 运行其 Code Mode 覆盖。控制与组合详见 [tui-agent/README.md](tui-agent/README.md)。 +运行:`pnpm run demo:code-mode`(需要 `DEEPSEEK_API_KEY`)。详见 [code-mode/README.md](code-mode/README.md)。交互式 agent 本身不再是示例:`pnpm run demo:tui` 在共享 base 之上启动 [`apps/cli/tui.cordis.yml`](../apps/cli/tui.cordis.yml),其 PTY 与快照场景位于 `apps/cli/tests/`。 ## jsonrpc-agent 通过 Python SDK 驱动的无人值守编码 agent:JSON-RPC stdio、仅前台 `bash`、`read`/`write`/`edit`、一个前台 `subagent`、`todo_write`、JSONL 持久化和压缩。它不包含终端 UI、stdout 日志、批准、skill(技能)和后台任务控制。详见 [jsonrpc-agent/README.md](jsonrpc-agent/README.md)。 -## cordis-agent +## web-cordis **自指** 演示:编码主干加 [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis),其三个工具(`cordis_inspect`/`cordis_mount`/`cordis_unmount`)使 agent 可以检查当前 DSH 进程、挂载模型编写的临时插件(事件监听器、一个全新工具,或一个供另一个临时插件注入的服务),并再次卸载它们。这些插件只存在于内存中,共享一个内部 `cordis-dynamic` fiber 子树;`ctx.fs`/`ctx.web` 仅作为它们可用的能力提供方。 +<<<<<<< HEAD 使用 `pnpm run demo:cordis` 运行 TUI,使用 `pnpm run demo:cordis web` 在 `http://127.0.0.1:3081` 启动浏览器 UI,或使用 `pnpm run demo:cordis acp` 启动 ACP 服务器(三者均需 `DEEPSEEK_API_KEY`)。分阶段演示脚本详见 [cordis-agent/README.md](cordis-agent/README.md),设计与沙箱注意事项详见[工具集 Agent Note(agent 决策记录)](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 +======= +使用 `pnpm run demo:cordis` 在 `http://127.0.0.1:3081` 启动浏览器 UI,或使用 `pnpm run demo:cordis acp` 启动 ACP 服务器(两者均需 `DEEPSEEK_API_KEY`)。设计与沙箱注意事项详见[工具集 Agent Note](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) ## acp-agent diff --git a/examples/tui-agent/README.i18n.yaml b/examples/code-mode/README.i18n.yaml similarity index 54% rename from examples/tui-agent/README.i18n.yaml rename to examples/code-mode/README.i18n.yaml index 863092ffb8..2b1171f393 100644 --- a/examples/tui-agent/README.i18n.yaml +++ b/examples/code-mode/README.i18n.yaml @@ -1,6 +1,12 @@ # 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: +<<<<<<< HEAD:examples/tui-agent/README.i18n.yaml # pnpm run verify-translation-pairing --write examples/tui-agent/README.md README.md: ea8695d37ea247a38644392a4572c1ea9855fd44 README.zh.md: c6acd39d8713816d870c00fa8597754d0d09880a +======= +# pnpm run verify-translation-pairing --write examples/code-mode/README.md +README.md: 1557483ee98fab60aa63f8c5ec40f7e592c275ba +README.zh.md: c20a9a7dde80eb0e03f17b62b5dc21189ef65f06 +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays):examples/code-mode/README.i18n.yaml diff --git a/examples/code-mode/README.md b/examples/code-mode/README.md new file mode 100644 index 0000000000..1557483ee9 --- /dev/null +++ b/examples/code-mode/README.md @@ -0,0 +1,35 @@ +# code-mode + +English | [中文](README.zh.md) + +Code Mode over the shipped TUI: the model stops calling one tool per turn and instead writes TypeScript programs for a single `run_code` tool, executed in a worker thread. + +This leaf is an **overlay**, not a tree. `dsh --config` includes the shared [`apps/cli/base.cordis.yml`](../../apps/cli/base.cordis.yml), applies the [`tui.cordis.yml`](../../apps/cli/tui.cordis.yml) surface overlay, then applies this file — all as sibling patch lists at one include level. + +## Run it + +```sh +# repo root .env (gitignored) or exported env: +# DEEPSEEK_API_KEY=sk-… +# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API +pnpm run demo:code-mode +``` + +`pnpm run demo:code-mode acp` boots the same idea over the ACP transport from [`examples/acp-agent/code-mode.cordis.yml`](../acp-agent/code-mode.cordis.yml). + +## What the overlay changes + +| Row | Change | +| --- | --- | +| `tools` | `mode: code` — the wire registry collapses to `run_code` plus its generated TypeScript SDK prompt section | +| `system-prompt` | a persona telling the model to batch tool work into one program | +| `tui` | a Code Mode welcome line | +| `code-runtime` | inserted: `@deepseek-ai/dsh-code-runtime-worker`, the worker thread `run_code` executes in | + +Everything else — the model backend, executors, filesystem tools, persistence, delegation, and the front door — comes from the base and the TUI overlay. + +A patch replaces a row's whole `config` rather than merging into it, so each row above restates every key it owns. A patch whose `id` matches no row is skipped with a Loader warning, so renaming a row in the base or the surface overlay requires updating this file. + +## Model Experience + +The model sees one tool instead of the full native registry. Its request carries the `run_code` schema and a generated TypeScript SDK section describing the callable surface, which costs more prompt tokens up front but replaces many single-call turns with one program — fewer round trips and fewer intermediate tool results in the transcript. Because the tool catalog is part of the cached request prefix, switching modes invalidates the KV cache for the session. diff --git a/examples/code-mode/README.zh.md b/examples/code-mode/README.zh.md new file mode 100644 index 0000000000..c20a9a7dde --- /dev/null +++ b/examples/code-mode/README.zh.md @@ -0,0 +1,35 @@ +# code-mode + +[English](README.md) | 中文 + +在交付的 TUI 之上启用 Code Mode:模型不再每轮调用一个工具,而是为单一的 `run_code` 工具编写 TypeScript 程序,并在 worker 线程中执行。 + +本叶节点是一个 **overlay**,而不是配置树。`dsh --config` 会 include 共享的 [`apps/cli/base.cordis.yml`](../../apps/cli/base.cordis.yml),应用 [`tui.cordis.yml`](../../apps/cli/tui.cordis.yml) surface overlay,再应用本文件——三者都是同一 include 层级上的平级 patch 列表。 + +## 运行 + +```sh +# repo root .env (gitignored) or exported env: +# DEEPSEEK_API_KEY=sk-… +# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API +pnpm run demo:code-mode +``` + +`pnpm run demo:code-mode acp` 通过 ACP 传输启动同样的思路,配置位于 [`examples/acp-agent/code-mode.cordis.yml`](../acp-agent/code-mode.cordis.yml)。 + +## 该 overlay 改动了什么 + +| 配置项 | 改动 | +| --- | --- | +| `tools` | `mode: code`——线上注册表收敛为 `run_code`,外加其生成的 TypeScript SDK 提示词片段 | +| `system-prompt` | 指示模型把相关工具调用批量写进一个程序的 persona | +| `tui` | Code Mode 的欢迎行 | +| `code-runtime` | 新插入:`@deepseek-ai/dsh-code-runtime-worker`,即 `run_code` 的执行 worker 线程 | + +其余部分——模型后端、执行器、文件系统工具、持久化、委派与前端入口——全部来自 base 与 TUI overlay。 + +配置 patch 会整体替换该配置项的 `config`,而非与其合并,因此上表每一行都必须重述自己拥有的全部键。若某个 patch 的 `id` 不再匹配任何配置项,Loader 只会告警并跳过它,所以在 base 或 surface overlay 中重命名配置项时必须同步更新本文件。 + +## Model Experience(模型体验) + +模型看到的是一个工具,而不是完整的原生注册表。其请求携带 `run_code` 的 schema 以及一段生成的 TypeScript SDK 说明,用于描述可调用面。这会在前置阶段消耗更多提示词 token,但把多个单次调用的轮次压缩为一个程序——往返更少,transcript 中的中间工具结果也更少。由于工具目录属于被缓存的请求前缀,切换模式会使该会话的 KV 缓存失效。 diff --git a/examples/code-mode/cordis.yml b/examples/code-mode/cordis.yml new file mode 100644 index 0000000000..551b318647 --- /dev/null +++ b/examples/code-mode/cordis.yml @@ -0,0 +1,36 @@ +# Code Mode over the shipped TUI: the model stops calling one tool per turn and +# instead writes TypeScript programs for a single `run_code` tool, executed in a +# worker thread. +# +# This file is a `--config` OVERLAY, not a tree: `dsh --config +# examples/code-mode/cordis.yml` includes the shared base, applies the TUI +# overlay, then applies these patches, all at one include level. Patches never +# cross an include boundary, so an overlay must stay a patch list rather than a +# file that includes another config. +# +# A patch replaces the targeted row's whole `config`, so each row restates every +# key it owns. +- id: tools + config: + mode: code + +- id: system-prompt + config: + persona: | + You are a coding agent powered by the {{model}} model. + + You work by writing TypeScript programs for run_code: batch related + tool work into one program, loop and branch where it helps, and print + or return ONLY the findings that matter. + +- id: tui + config: + sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main' + welcome: 'TUI Code Mode ready. Give it a multi-tool task.' + showReasoning: true + maxToolOutputLines: 6 + +# The worker thread `run_code` executes in; the base mounts no code runtime. +- insert: + - id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' diff --git a/examples/code-mode/package.json b/examples/code-mode/package.json new file mode 100644 index 0000000000..9a5ec92480 --- /dev/null +++ b/examples/code-mode/package.json @@ -0,0 +1,7 @@ +{ + "name": "code-mode-example", + "private": true, + "version": "0.0.1", + "type": "module", + "description": "Runnable overlay: the shipped TUI composition with the model-facing registry reduced to the run_code transport" +} diff --git a/examples/cordis-agent/README.md b/examples/cordis-agent/README.md deleted file mode 100644 index 55970e932b..0000000000 --- a/examples/cordis-agent/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# cordis-agent - -English | [中文](README.zh.md) - -The self-referential harness demo: the DeepSeek V4 coding spine on the full-screen TUI plus [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md), which lets the model inspect the current DSH process, mount in-memory temporary Plugins, and unmount them. Temporary Plugins remain active across turns but disappear on unmount, toolset unload, or DSH restart; they create no files or configuration and may affect other sessions in the process. The `ctx.fs` and `ctx.web` services are provider-only capabilities available to those Plugins. The design lives in [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). - -## Run it - -```sh -# repo root .env (gitignored) or exported env: -# DEEPSEEK_API_KEY=sk-… -# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:cordis # TUI (default) -pnpm run demo:cordis web # browser UI at http://127.0.0.1:3081 -pnpm run demo:cordis acp # ACP server -``` - -The intended demo is staged — verify the listener link first, then let the agent extend itself: - -``` -> Mount a temporary Plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash. - [tool call] cordis_mount({"code": "return { name: 'status-logger', apply(ctx) { ctx.on('agent/status', (agent, status) => console.log('status →', status)) } }"}) - [tool result] Temporary Plugin dyn-1 is running (plugin "status-logger"; available until unmounted or DSH restarts). - [tool call] bash({"command": "echo hi"}) -[cordis:dyn-1] status → … ← the temporary listener firing, live -> Now give yourself a reverse_text tool and use it on "harness". - [tool call] cordis_mount({"code": "return { name: 'reverse-text', inject: ['tools'], apply(ctx) { ctx.tools.register(harness.defineTool({ name: 'reverse_text', … })) } }"}) - [tool call] reverse_text({"text": "harness"}) ← a tool the agent built for itself, one step earlier -> Unmount both temporary Plugins. - [tool call] cordis_unmount({"id": "dyn-1"}) -``` - -Ask for `cordis_inspect` with `what: "api"` or `what: "events"` to see the generated service/event reference used to write Plugin code, and mount two cooperating temporary Plugins (`ctx.provide` in one, `inject` in the other) to watch Cordis park and revive the consumer. - -## End-to-end tests - -`tests/keyless-smoke.e2e.ts` boots the real `cordis.yml` through the Loader with a dummy key and asserts the banner, package-name resolution, and clean EOF exit. `tests/cordis-tools.e2e.ts` is the with-key smoke: a real model mounts a temporary status listener and the test verifies its tagged console line, creates and uses a `reverse_text` tool, and composes two temporary Plugins through provide/inject. [`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) carries the unit coverage under the per-file 100% gate. diff --git a/examples/cordis-agent/composition.md b/examples/cordis-agent/composition.md deleted file mode 100644 index 02d68215f5..0000000000 --- a/examples/cordis-agent/composition.md +++ /dev/null @@ -1,55 +0,0 @@ - - -# Cordis Agent App Composition - -The self-referential demo puts @deepseek-ai/dsh-tool-cordis on the coding spine, letting the agent inspect its current-process runtime and mount or unmount in-memory temporary Plugins. - -```mermaid -flowchart LR - cfg["examples/cordis-agent
cordis.yml"] - plugin_cordis_hmr["hmr
@cordisjs/plugin-hmr"] - cfg --> plugin_cordis_hmr - plugin_cordis_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] - cfg --> plugin_cordis_llm_deepseek - plugin_cordis_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] - cfg --> plugin_cordis_subprocess - plugin_cordis_bash["bash
@deepseek-ai/dsh-bash-local"] - cfg --> plugin_cordis_bash - plugin_cordis_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] - cfg --> plugin_cordis_fs_local - plugin_cordis_web["web
@deepseek-ai/dsh-web"] - cfg --> plugin_cordis_web - plugin_cordis_web_fetch_local["web-fetch-local
@deepseek-ai/dsh-web-fetch-local"] - cfg --> plugin_cordis_web_fetch_local - plugin_cordis_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] - cfg --> plugin_cordis_token_meter - plugin_cordis_tui_agent["tui-agent
@deepseek-ai/dsh-tui-demo"] - cfg --> plugin_cordis_tui_agent - plugin_cordis_tui_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] - plugin_cordis_tui_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_cordis_tui_agent --> frontdoor_tui["@deepseek-ai/dsh-tui
pre-created main agent"] - bundle_agent_core --> spine_llm["ctx.llm"] - bundle_agent_core --> spine_sessions["ctx.sessions"] - bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] - bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] - plugin_cordis_tool_cordis["tool-cordis
@deepseek-ai/dsh-tool-cordis"] - cfg --> plugin_cordis_tool_cordis -``` - -| Plugin id | Package / module | -| --- | --- | -| `hmr` | `@cordisjs/plugin-hmr` | -| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | -| `bash` | `@deepseek-ai/dsh-bash-local` | -| `fs-local` | `@deepseek-ai/dsh-fs-local` | -| `web` | `@deepseek-ai/dsh-web` | -| `web-fetch-local` | `@deepseek-ai/dsh-web-fetch-local` | -| `token-meter` | `@deepseek-ai/dsh-token-meter` | -| `tui-agent` | `@deepseek-ai/dsh-tui-demo` | -| `tool-cordis` | `@deepseek-ai/dsh-tool-cordis` | - -Source config: [`examples/cordis-agent/cordis.yml`](cordis.yml). - -Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml deleted file mode 100644 index 601ac94419..0000000000 --- a/examples/cordis-agent/cordis.yml +++ /dev/null @@ -1,87 +0,0 @@ -# Self-referential TUI demo: the coding spine plus tools to inspect the live -# service/plugin/tool/temporary/API/event state, mount a model-written temporary -# Plugin, and quiescently unmount it. The app bin loads the gitignored -# root `.env` before reading the required DeepSeek key and optional base URL. -# Trust stance: the vm and context façade limit accidental global/framework -# access but are not a security boundary; temporary Plugin code reaches live capabilities -# such as `ctx.bash`. Grant this toolset like bash access. See -# ../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md. - -# Development-only hot reload; production assemblies omit it. -- id: hmr - name: '@cordisjs/plugin-hmr' - config: - root: ['.'] - -# The DeepSeek adapter. Shipped default: full thinking at max effort on every -# request (wire-only defaults; they never enter the request header). -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - thinking: enabled - reasoningEffort: max - -# Local bash executor for agent-spine-demo's tool-bash schema — gives the agent an -# ordinary tool whose calls make the mounted listeners observably fire. -# Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' - -- id: bash - name: '@deepseek-ai/dsh-bash-local' - config: - timeoutMs: 60000 - -# Filesystem service for mounted plugins (ctx.fs) — the local provider only. -# The model-facing read/write/edit tools stay unmounted on purpose: this demo -# is about the agent building its own tools over the services. -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - -# Web service for mounted plugins (ctx.web): the seam plus the anonymous local -# fetch provider (keyless). No search provider is loaded — ctx.web search -# calls fail loud until a deployment adds one. -- id: web - name: '@deepseek-ai/dsh-web' - -- id: web-fetch-local - name: '@deepseek-ai/dsh-web-fetch-local' - -- id: token-meter - name: '@deepseek-ai/dsh-token-meter' - -# The app bundle pre-creates the self-referential demo's `main` agent. -- id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' - config: - provider: deepseek - model: deepseek-v4-pro - persistenceRoot: './.sessions' - workspaceContext: - maxBytes: 65536 - welcome: 'cordis-agent ready. Ask it to inspect its runtime, mount a temporary listener, or invent a temporary tool for itself.' - persona: | - You are cordis-agent, a self-referential harness demo powered by the - {{model}} model. - - You run INSIDE a cordis plugin runtime, and your cordis_* tools operate - on that live runtime: cordis_inspect to look around (its `api` and - `events` sections document the service methods, type shapes, and events - your Plugin code can use), cordis_mount to mount an in-memory temporary - Plugin (an event listener, a brand-new tool for yourself, or a service - another temporary Plugin injects), cordis_unmount to clean one up. These - Plugins remain across turns but disappear on unmount, toolset unload, or - DSH restart and may affect other sessions in this process. In Plugin code, NEVER use Node - built-ins (require/setTimeout/fetch) — use the runtime's cordis services - via inject: fs, web, bash, and timer (ctx.setTimeout). Prefer small - single-purpose plugins, prefer plain notification events over waterfall - events unless you intend to intercept, and unmount what you no longer - need. Report results briefly. - -# The self-referential cordis toolset (loaded after the app so ctx.tools exists). -- id: tool-cordis - name: '@deepseek-ai/dsh-tool-cordis' diff --git a/examples/cordis-agent/package.json b/examples/cordis-agent/package.json deleted file mode 100644 index 8d5a693555..0000000000 --- a/examples/cordis-agent/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "cordis-agent-example", - "private": true, - "version": "0.0.1", - "type": "module", - "description": "Runnable demo: the self-referential harness — an agent that inspects and modifies its own cordis runtime" -} diff --git a/examples/cordis-agent/tests/cordis-tools.e2e.ts b/examples/cordis-agent/tests/cordis-tools.e2e.ts deleted file mode 100644 index 8589ab77ec..0000000000 --- a/examples/cordis-agent/tests/cordis-tools.e2e.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { Context } from 'cordis' -import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' -import { cordisHarness, waitForIdle } from './harness.ts' -import { SessionId } from '@deepseek-ai/dsh-session' - -const testToolSignal = new AbortController().signal - -/** - * With-key smoke for the self-referential cordis tools: a REAL model drives - * cordis_mount/cordis_unmount against the live context the test observes. - * World-verified, not self-reported: the mounted listener must actually WRITE - * its tagged console line, the self-made tool must actually EXIST in the - * registry and appear as a real `tool/call`, the cross-mount service must - * actually LAND in the reflect store. Key-gated (see vitest.e2e.config.ts). - */ - -let ctx: Context | undefined - -afterEach(async () => { - vi.restoreAllMocks() - // Always dispose the harness, even on failure/retry/timeout: agent-loop - // teardown stops the loop, and disposing the tree unwinds every dynamic - // mount the model left behind. - await ctx?.fiber.dispose() - ctx = undefined -}) - -/** The tagged write-through lines (`[cordis:dyn-n] …`) captured by a console spy. */ -function taggedCalls(log: { mock: { calls: unknown[][] } }): unknown[][] { - return log.mock.calls.filter(call => typeof call[0] === 'string' && /^\[cordis:dyn-\d+\]$/.test(call[0])) -} - -/** Model-facing text of one tool result, concatenated. */ -function resultText(result: { content: { type: string; text?: string }[] }): string { - return result.content.filter(block => block.type === 'text').map(block => block.text).join('') -} - -describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modifies its own runtime', () => { - it('mounts a temporary status listener whose tagged output actually fires, then unmounts it', async () => { - ctx = await cordisHarness() - const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - const agent = ctx.agentLoop.create(SessionId('cordis-e2e-listener'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - - agent.followup(createUserMessage({ - content: [{ - type: 'text', - text: 'Use cordis_mount to create a temporary Plugin that listens to the \'agent/status\' ' - + 'Cordis event and logs every change with console.log. Reply "running" once done.', - }], source: { kind: 'user' } })) - await waitForIdle(ctx, agent) - - // The WORLD check: the turn's own running→idle transition must have driven - // the mounted listener through the tagged sandbox console. - expect(taggedCalls(log).length).toBeGreaterThan(0) - const mid = await ctx.tools.execute({ - signal: testToolSignal, - callId: CallId('verify-mounted'), name: 'cordis_inspect', arguments: { what: 'temporary' }, - }) - expect(resultText(mid)).toContain('dyn-') - - agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Now unmount the temporary Plugin you just mounted.' }], source: { kind: 'user' } })) - await waitForIdle(ctx, agent) - - const after = await ctx.tools.execute({ - signal: testToolSignal, - callId: CallId('verify-unmounted'), name: 'cordis_inspect', arguments: { what: 'temporary' }, - }) - expect(resultText(after)).toContain('No temporary Plugins are running.') - }, 120_000) - - it('builds itself a reverse_text tool and actually calls it', async () => { - ctx = await cordisHarness() - const agent = ctx.agentLoop.create(SessionId('cordis-e2e-selftool'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - - agent.followup(createUserMessage({ - content: [{ - type: 'text', - text: 'Give yourself a new tool: use cordis_mount to create a temporary Plugin with ' - + 'inject ["tools"] that calls harness.registerTool(ctx, harness.defineTool({...})) ' - + 'to register a tool named reverse_text with one required string parameter ' - + '"text", returning the text reversed. Then CALL reverse_text with the ' - + 'exact text "harness" and report its exact output.', - }], source: { kind: 'user' } })) - await waitForIdle(ctx, agent) - - // World checks: the tool exists in the registry, was invoked as a real tool call, and its - // RESULT (the self-made execute actually running) is the reversed string. Model prose is only - // self-report and is deliberately not asserted. - expect(ctx.tools.get('reverse_text')).toBeDefined() - const events = [...agent.session.events] - const calls = events.filter(event => event.type === 'tool/call') - expect(calls.some(event => event.data.name === 'cordis_mount')).toBe(true) - const reverseCalls = calls.filter(event => event.data.name === 'reverse_text') - expect(reverseCalls.length).toBeGreaterThan(0) - const reverseResults = events - .filter(event => event.type === 'tool/result') - .filter(event => reverseCalls.some(call => call.data.callId === event.data.message.source.callId)) - .flatMap(event => event.data.message.content[0].content.filter(block => block.type === 'text').map(block => block.text)) - // On failure, surface what the model actually mounted and what the tool - // returned — an e2e failing at a distance is undebuggable without it. - const mountCode = calls - .filter(event => event.data.name === 'cordis_mount') - .map(event => event.data.arguments) - .join('\n---\n') - const trace = events.map((event) => { - switch (event.type) { - case 'tool/call': return `tool/call:${event.data.name}` - case 'tool/result': return `tool/result:${event.data.message.content[0].isError ? 'ERR:' + JSON.stringify(event.data.message.content[0].content).slice(0, 200) : 'ok'}` - case 'turn/end': return `turn/end:${JSON.stringify(event.data.reason)}` - default: return event.type - } - }).join('\n') - expect( - reverseResults.some(text => text.includes('ssenrah')), - `no reversed output in reverse_text results.\nresults: ${JSON.stringify(reverseResults)}\nmount code: ${mountCode}\ntrace:\n${trace}`, - ).toBe(true) - }, 120_000) - - it('composes two temporary Plugins through provide/inject, and unmounting the provider parks the consumer', async () => { - ctx = await cordisHarness() - const agent = ctx.agentLoop.create(SessionId('cordis-e2e-compose'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - - agent.followup(createUserMessage({ - content: [{ - type: 'text', - text: 'Mount TWO separate temporary Plugins with cordis_mount. First a provider: apply calls ' - + 'ctx.provide(\'shouter\', { shout: (s) => s.toUpperCase() }). Second a consumer with ' - + 'inject ["shouter", "tools"] that registers (via harness.registerTool + harness.defineTool) ' - + 'a tool named shout_text with one required string parameter "text" whose execute returns ' - + 'ctx.shouter.shout(args.text) as a text content block. Then CALL shout_text with "quiet" ' - + 'and report the exact output.', - }], source: { kind: 'user' } })) - await waitForIdle(ctx, agent) - - // World checks: the service is really in the store, the tool really ran. - expect(ctx.get('shouter')).toBeDefined() - expect(ctx.tools.get('shout_text')).toBeDefined() - const events = [...agent.session.events] - const shoutCalls = events - .filter(event => event.type === 'tool/call') - .filter(event => event.data.name === 'shout_text') - expect(shoutCalls.length).toBeGreaterThan(0) - const shoutResults = events - .filter(event => event.type === 'tool/result') - .filter(event => shoutCalls.some(call => call.data.callId === event.data.message.source.callId)) - .flatMap(event => event.data.message.content[0].content.filter(block => block.type === 'text').map(block => block.text)) - expect(shoutResults.some(text => text.includes('QUIET'))).toBe(true) - - agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Now unmount ONLY the provider temporary Plugin (the one that provided shouter).' }], source: { kind: 'user' } })) - await waitForIdle(ctx, agent) - - // The consumer must have been parked by cordis itself: service gone, - // dependent tool unregistered, temporary section naming the missing service. - expect(ctx.get('shouter')).toBeUndefined() - expect(ctx.tools.get('shout_text')).toBeUndefined() - const after = await ctx.tools.execute({ - signal: testToolSignal, - callId: CallId('verify-parked'), name: 'cordis_inspect', arguments: { what: 'temporary' }, - }) - expect(resultText(after)).toContain('waiting for: shouter') - }, 120_000) -}) diff --git a/examples/cordis-agent/tests/harness.ts b/examples/cordis-agent/tests/harness.ts deleted file mode 100644 index 2e12cd76c3..0000000000 --- a/examples/cordis-agent/tests/harness.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Context } from 'cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' - -/** - * Shared harness for the cordis-agent e2e suite: the agent spine with the real - * DeepSeek adapter and the real `@deepseek-ai/dsh-tool-cordis` plugin, so a - * live model can mount plugins into the very context the test observes. Lives - * outside the *.e2e.ts pattern so importing it never re-registers another - * file's tests. - */ - -const PERSONA = 'You are cordis-agent, a self-referential harness demo. ' - + 'Your cordis_* tools operate on the live cordis runtime you run inside: ' - + 'cordis_inspect to look around, cordis_mount to mount a temporary Plugin, cordis_unmount ' - + 'to unmount one. Follow the tool descriptions exactly and report results briefly.' - -export async function cordisHarness(): Promise { - const ctx = new Context() - await mountAgentLoopTestDependencies(ctx, { - systemPrompt: { persona: PERSONA }, - }) - await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LlmDeepSeek) - await ctx.plugin(ToolCordis) - return ctx -} - -export function waitForIdle(ctx: Context, agent: Agent): Promise { - return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { - if (subject === agent && status === 'idle') { - dispose() - resolve() - } - }) - }) -} diff --git a/examples/cordis-agent/tests/keyless-smoke.e2e.ts b/examples/cordis-agent/tests/keyless-smoke.e2e.ts deleted file mode 100644 index c340eea036..0000000000 --- a/examples/cordis-agent/tests/keyless-smoke.e2e.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke' -import { runTuiPtySmoke } from '../../tui-agent/tests/pty-harness.ts' - -const binScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) - -describe('cordis-agent keyless smoke (real Loader tree in a PTY)', () => { - it('boots the full tool-cordis tree and exits cleanly through the TUI', async () => { - const output = await runTuiPtySmoke({ - label: 'cordis-agent', - tempDirPrefix: 'cordis-agent-smoke-', - binScript, - configPath, - tsconfigPath, - env: { DEEPSEEK_API_KEY: 'keyless-smoke-no-call' }, - actions: [{ waitFor: 'cordis-agent ready.', send: '/exit\r' }], - }) - expect(output).toContain('cordis-agent ready.') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) -}) diff --git a/examples/package.json b/examples/package.json index b6f06057e3..88e1e8f57a 100644 --- a/examples/package.json +++ b/examples/package.json @@ -3,11 +3,14 @@ "private": true, "version": "0.0.1", "type": "module", - "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports→lib. Not a build target.", + "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports\u2192lib. Not a build target.", "dependencies": { "@cordisjs/plugin-hmr": "workspace:*", "@cordisjs/plugin-include": "workspace:*", + "@cordisjs/plugin-timer": "workspace:*", "@deepseek-ai/dsh-acp-demo": "workspace:*", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-loop": "workspace:*", "@deepseek-ai/dsh-agent-spine-demo": "workspace:*", "@deepseek-ai/dsh-app-boot": "workspace:*", "@deepseek-ai/dsh-bash": "workspace:*", @@ -15,6 +18,8 @@ "@deepseek-ai/dsh-bash-sandbox": "workspace:*", "@deepseek-ai/dsh-cli-demo": "workspace:*", "@deepseek-ai/dsh-code-runtime-worker": "workspace:*", + "@deepseek-ai/dsh-command-goal": "workspace:*", + "@deepseek-ai/dsh-commands": "workspace:*", "@deepseek-ai/dsh-compact-basic": "workspace:*", "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", @@ -24,6 +29,7 @@ "@deepseek-ai/dsh-goal-session": "workspace:*", "@deepseek-ai/dsh-hooks-claude": "workspace:*", "@deepseek-ai/dsh-hooks-codex": "workspace:*", + "@deepseek-ai/dsh-invariants": "workspace:*", "@deepseek-ai/dsh-jsonrpc": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek": "workspace:*", @@ -33,32 +39,40 @@ "@deepseek-ai/dsh-lsp-local": "workspace:*", "@deepseek-ai/dsh-permission": "workspace:*", "@deepseek-ai/dsh-plan-mode": "workspace:*", - "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-pty": "workspace:*", "@deepseek-ai/dsh-pty-local": "workspace:*", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-session-query": "workspace:*", "@deepseek-ai/dsh-session-query-sqlite": "workspace:*", + "@deepseek-ai/dsh-session-reference": "workspace:*", "@deepseek-ai/dsh-session-telemetry-otel": "workspace:*", + "@deepseek-ai/dsh-session-title": "workspace:*", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:*", + "@deepseek-ai/dsh-skill": "workspace:*", + "@deepseek-ai/dsh-skill-local": "workspace:*", "@deepseek-ai/dsh-source-guard": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-acp": "workspace:*", - "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", + "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", + "@deepseek-ai/dsh-subprocess-local": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-tasks-local": "workspace:*", "@deepseek-ai/dsh-time-context": "workspace:*", "@deepseek-ai/dsh-timeout-policy": "workspace:*", "@deepseek-ai/dsh-tmux-context": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-tool-ask-user": "workspace:*", + "@deepseek-ai/dsh-tool-bash": "workspace:*", "@deepseek-ai/dsh-tool-cordis": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*", @@ -67,18 +81,19 @@ "@deepseek-ai/dsh-tool-pty": "workspace:*", "@deepseek-ai/dsh-tool-ralph": "workspace:*", "@deepseek-ai/dsh-tool-session-query": "workspace:*", + "@deepseek-ai/dsh-tool-skill": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", + "@deepseek-ai/dsh-tool-tasks": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-web": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", - "@deepseek-ai/dsh-tui-demo": "workspace:*", + "@deepseek-ai/dsh-tui": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", + "@deepseek-ai/dsh-user-interaction": "workspace:*", "@deepseek-ai/dsh-web": "workspace:*", "@deepseek-ai/dsh-web-fetch-local": "workspace:*", - "@deepseek-ai/dsh-workflow-workerthread": "workspace:*" - }, - "devDependencies": { - "node-pty": "1.1.0" + "@deepseek-ai/dsh-workflow-workerthread": "workspace:*", + "@deepseek-ai/dsh-workspace-context": "workspace:*" } } diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md deleted file mode 100644 index eae5994fd8..0000000000 --- a/examples/tui-agent/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# tui-agent - -English | [中文](README.zh.md) - -The full-screen interactive coding agent: DeepSeek V4, local bash and filesystem tools, compaction, subagents, workflows and fresh-agent Ralph iteration, plan mode (`/plan` enters and `exit_plan_mode` reviews the exit), timeout/spill policy, and JSONL persistence through [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo), loaded from `cordis.yml`. The sibling [`headless-agent`](../headless-agent/README.md) runs the same capability class as a one-shot pipe-friendly task, and [`acp-agent`](../acp-agent/README.md) serves it over JSON-RPC. - -## Run it - -```sh -# repo root .env (gitignored) or exported env: -# DEEPSEEK_API_KEY=sk-… -# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:tui -``` - -Both the demo script and the installable `dsh` CLI ([`apps/cli`](../../apps/cli/README.md)) boot this example's `cordis.yml` as the shipped default config; `dsh` additionally applies the personal overlay from `~/.dsh` and uses the invoking directory as the workspace. - -Type a coding task. The agent works through the `read`/`write`/`edit` filesystem tools for ordinary file operations and `bash` (+ the generic `task_output` / `task_list` / `task_kill` for background tasks) for shell commands, searches, and test runs, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). Both the fs tools and bash resolve relative paths against the session workspace. It can also delegate with `subagent`/`subagent_fork`. - -The `todo_write` task tracker is opt-in and not in the shipped config: add `@deepseek-ai/dsh-tool-todo` to `cordis.yml` (or a personal-config overlay under `~/.dsh`) to expose it. Once loaded, the model records a whole-list plan to the session log and the TUI renders it. - -The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and — when `todo_write` is loaded — the latest plan. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/plan` selects plan mode for the next step; `/plan ` also submits the message into that step, while `/plan off` selects the default mode without model input. `/status` expands the current session's identity, activity counts, exact token/cache buckets, context use, and timestamps without interrupting a running turn. `/model` opens a keyboard selector for the current provider catalog; use Up/Down to focus a model, Shift+Tab to cycle its advertised reasoning efforts, and Enter to select, or use `/model ` and `/model /` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options. - -### Resuming a prior session - -Each run starts a fresh session by default (its event log lands under `./.sessions/`). To **continue** a previous conversation, pass its id to the installed `dsh` CLI — the `main` agent then rehydrates the persisted log instead of starting fresh, so the model sees the earlier turns as history: - -```sh -dsh --resume -``` - -`/resume` opens a searchable keyboard selector with titles, activity, last-turn results, model route, durable goal phase, and live/persisted state. The installed `dsh` host flushes and disposes the current app, then replaces the process with `dsh --resume `; a host that cannot hand off in place says so and leaves the session running. Resume needs no key in this file: `dsh` provides the session identity and the exit line on the boot context, so `--resume ` and the printed resume command survive any personal-overlay patch of the `tui-agent` entry. With no flag the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. The selector has no cross-process session lock, so deployments with concurrent hosts must coordinate session ownership separately. - -## Code Mode - -[`code-mode.cordis.yml`](code-mode.cordis.yml) overlays the same tree with the worker-thread runtime and `tools: { mode: code }`. The model receives one `run_code` transport plus a generated TypeScript SDK for the visible tools; only program output returns to model context. Use `mode: both` to expose native calls alongside `run_code`. See the [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) for the execution contract. - -```sh -pnpm run demo:code-mode # this overlay under the TUI (default UI) -pnpm run demo:code-mode acp # the acp-agent example's same-shaped overlay -``` - -Try a task that spans several tool calls, e.g.: - -> Count the lines of every `*.md` file under docs/ and write the three largest to summary.txt. - -and watch the transcript: one `run_code` call, a program looping over tools, and a result the model curated instead of five round-trips of raw tool output. - -## What each leaf entry demonstrates - -This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads one app package, and adds product tools that are intentionally outside the shared spine. The spine (sessions, system-prompt, tools, agents, invariants, `agent-loop`) and the front-door cluster (JSONL persistence, the pi-tui channel, the pre-created `main` agent) live inside the [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo) app and the [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle it loads; the leaf wires the backends and model-facing optional tools: - -| Entry | Demonstrates | -|---|---| -| `hmr` (`@cordisjs/plugin-hmr`) | the dev/demo edit-reload loop — a **leaf** entry (not baked into the app) because it depends on the Loader's internal module access | -| `llm-deepseek` | the default native adapter | -| `bash` (`dsh-bash-local`) | the executor implementation — the swappable half of the bash seam. The model-facing `bash` schema (`tool-bash`) and generic `task_*` controls (`tool-tasks`) come from `dsh-agent-spine-demo`, so only the executor is a leaf choice | -| `tui-agent` (`@deepseek-ai/dsh-tui-demo`) | the app bundle: the agent-spine demo + JSONL persistence + the pi-tui channel + a pre-created `main` agent | -| `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix | -| `tool-subagent`, `tool-subagent-fork` | two model-facing `dsh-tool-subagent` loads, each bound to a different provider and exposed under a distinct tool name (`subagent`, `subagent_fork`) | -| `workflow-workerthread`, `tool-workflow` | the worker-thread workflow engine and its model-facing `workflow` tool, with child calls routed through the spawn backend | -| `plan-mode` | the plugin-owned `/plan [message]` entry and `/plan off` exit commands, plan-mode prompt policy, tool restrictions, and reviewed `exit_plan_mode` transition | -| `fs-local`, `fs-policy`, `tool-fs` | the filesystem stack: the local `ctx.fs` provider, the read-before-write/edit policy gate (on the `fs/*` event gate), and the model-facing `read`/`write`/`edit` tools. Relative paths resolve against the session workspace | - -## End-to-end tests (`pnpm run test:e2e`) - -The UI-independent with-key suites assemble the full stack programmatically through `tests/harness.ts` (no PTY, no Loader): - -- `tests/full-loop.e2e.ts` — the canary: real model runs `echo e2e-ok` through the real bash tool; asserts `tool/call`/`tool/result` session events and the final answer. -- `tests/coding-task.e2e.ts` — the swebench-style smoke: a temp dir holds `add.js` (with `a - b` where `a + b` belongs) and a failing `add.test.js`; the agent must fix the bug and verify. The test re-runs `node add.test.js` ITSELF and inspects the files — agent claims are not trusted. -- `tests/resume.e2e.ts` — durable continuity across processes: run 1 tells the real model a secret code and persists the turn to a temp JSONL root, then the whole context is disposed; run 2 is a fresh context over the same root that RESUMES the session id and asks the model to recall the code. The recall can only come from the rehydrated log. -- `tests/compaction.e2e.ts` — the compaction smoke: a real multi-step bash task runs with a deliberately tiny context window so the auto-compaction listener fires MID-SESSION. Verifies the WORLD — a `compact/start…end` pair landed in the real log, the surface shrank (a replace node shadowed older nodes), and the agent still produced a correct final answer after compaction. -- `tests/todo-write.e2e.ts` — loads the opt-in `todo_write` tool, then a real model drives it and the test verifies the resulting `todo/write` session event. -- `tests/code-mode.e2e.ts` — the with-key Code Mode proof: a real model, a two-tool task, asserting the wire tool list was exactly `[run_code]`, the `tool/code-dispatch` events landed under the parent call, and the curated answer came back. - -These self-skip without `DEEPSEEK_API_KEY`. The keyless `tests/tui-keyless-smoke.e2e.ts` boots the real Loader tree in a PTY (the one sanctioned PTY surface): the base boot + `/plan` + `/exit`, a scripted-LLM conversation with a question dialog and tool round-trip, the Code Mode overlay welcome line, and the resume-failure exit path. - -## Snapshot tests - -`tests/snapshots//session.jsonl` supplies recorded user prompts and model chunks; sibling child logs drive subagents and workflows. The keyless suite executes those scripts through the real loop and tool implementations, then compares readable expected terminal cell/style output. Use `pnpm run test:snapshot:refresh` for presentation-only changes and `pnpm run test:snapshot:record` with a DeepSeek key when a recorded model journey changes. The implemented [TUI snapshot Agent Note](../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns the scenario matrix and the split between recorded journeys, transient package snapshots, and PTY coverage. diff --git a/examples/tui-agent/code-mode.cordis.yml b/examples/tui-agent/code-mode.cordis.yml deleted file mode 100644 index eec54fe6ad..0000000000 --- a/examples/tui-agent/code-mode.cordis.yml +++ /dev/null @@ -1,30 +0,0 @@ -# Code Mode keeps the TUI composition while adding the worker runtime and -# reducing the model-facing registry to the `run_code` transport. -- id: base - name: '@cordisjs/plugin-include' - config: - path: ./cordis.yml - patches: - - id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' - config: - provider: deepseek - model: deepseek-v4-pro - persistenceRoot: './.sessions' - workspaceContext: - maxBytes: 65536 - tools: - mode: code - welcome: 'TUI Code Mode ready. Give it a multi-tool task.' - ui: - showReasoning: true - maxToolOutputLines: 6 - persona: | - You are a coding agent powered by the {{model}} model. - - You work by writing TypeScript programs for run_code: batch related - tool work into one program, loop and branch where it helps, and print - or return ONLY the findings that matter. - - insert: - - id: code-runtime - name: '@deepseek-ai/dsh-code-runtime-worker' diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml deleted file mode 100644 index b3f31ee928..0000000000 --- a/examples/tui-agent/cordis.yml +++ /dev/null @@ -1,201 +0,0 @@ -# Full-screen TUI coding agent with swappable model and local-bash backends. -# `dsh-tui-demo` supplies the agent spine, workspace instructions, generic -# task controls, JSONL persistence, the pi-tui front door, and `main`. -# HMR remains a leaf because it depends on Loader internals. The app bin loads -# the gitignored root `.env`; this file reads `DEEPSEEK_API_KEY` and optional -# `DEEPSEEK_BASE_URL` through `!!js`. - -- id: hmr - name: '@cordisjs/plugin-hmr' - config: - root: ['.'] - -# The native DeepSeek adapter. Shipped default: full thinking at max effort on -# every request (wire-only defaults; they never enter the request header). -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - thinking: enabled - reasoningEffort: max - -- id: llm-pi-ai - name: '@deepseek-ai/dsh-llm-pi-ai' - config: - providers: - - provider: openai - apiKey: !!js process.env.OPENAI_API_KEY - baseURL: !!js process.env.OPENAI_BASE_URL - - provider: anthropic - apiKey: !!js process.env.ANTHROPIC_API_KEY - baseURL: !!js process.env.ANTHROPIC_BASE_URL - -# Local executor for the app bundle's bash tool. -# Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' - -- id: bash - name: '@deepseek-ai/dsh-bash-local' - config: - timeoutMs: 60000 - -# The app bundle pre-creates the TUI's `main` agent. -- id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' - config: - provider: deepseek - model: deepseek-v4-pro - # Session identity and the resume command printed on exit are launcher-owned: - # `dsh` provides both on the boot context, so `--resume ` and the exit - # hint need no key here. `persistenceRoot` is omitted: the dsh launcher - # supplies its shared Harness-home store through a boot slot, and a bare - # example boot falls back to the bundle's project-local `./.sessions`. - workspaceContext: - maxBytes: 65536 - ui: - showReasoning: true - maxToolOutputLines: 6 - # Keep the persona to identity and behavior; tool plugins own tool guidance. - # The loop resolves {{model}} from this agent's configuration. - persona: | - You are a coding agent powered by the {{model}} model. - - Verify your work by running the code or tests. Keep answers brief and - factual. - -# Model-made session titles on the first-message cadence: replaces the spine's -# deterministic fallback title with a short model summary. The TUI renders the -# logged `session/title` as the banner subtitle and the terminal window title. -# Omitting provider/model inherits the main request's exact route. -- id: session-title-llm - name: '@deepseek-ai/dsh-session-title-first-message-llm' - config: - targetWords: 5 - targetCjkCharacters: 10 - maxInputBytes: 4096 - maxOutputTokens: 64 - timeoutMs: 60000 - -# Replay-aware request pressure with one service-wide context window. -- id: token-meter - name: '@deepseek-ai/dsh-token-meter' - -- id: tool-result-prune - name: '@deepseek-ai/dsh-compact-tool-result-prune' - -# Summarize an older range after measured pressure or a canonical provider overflow. -# Service-wide policy provides pressure, retention, and one overflow-retry default. -- id: compact-basic - name: '@deepseek-ai/dsh-compact-basic' - -# Expose fresh-child `spawn` and completed-prefix `fork` through independent -# in-process backends. Each tool instance needs a distinct `toolName`; the registry -# rejects duplicates. These leaves follow the app because it provides `ctx.agents` and `ctx.tools`. -- id: subagent - name: '@deepseek-ai/dsh-subagent' - -- id: subagent-spawn - name: '@deepseek-ai/dsh-subagent-spawn' - config: - providerName: spawn - -- id: subagent-fork - name: '@deepseek-ai/dsh-subagent-fork' - config: - providerName: fork - -- id: tool-subagent - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent - -- id: tool-subagent-fork - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: fork - toolName: subagent_fork - - -# The worker-thread workflow engine fans a model-written JavaScript script's -# `agent()` calls out through the spawn backend; the adjacent tool exposes it to the model. -- id: workflow-workerthread - name: '@deepseek-ai/dsh-workflow-workerthread' - config: - provider: spawn - -- id: tool-workflow - name: '@deepseek-ai/dsh-tool-workflow' - -# A separate fixed consumer demonstrates fresh-agent Ralph iteration without -# changing the workflow tool or same-session goal behavior. -- id: tool-ralph - name: '@deepseek-ai/dsh-tool-ralph' - -# Plan mode gives the TUI plugin-owned /plan [message] entry and /plan off exit -# commands; the reviewed exit rides the TUI's user-interaction provider. -- id: plan-mode - name: '@deepseek-ai/dsh-plan-mode' - config: - section: | - You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. - - Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. - - The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. - - Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. - - Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. - - When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. - -# Policy loads before the model-facing filesystem tools so writes and edits require -# an observed file. This single-session app resolves relative paths from the process cwd. -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - -- id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - -- id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' - -# Refuses write/edit inside the dsh checkout this launcher runs from, on that -# checkout's own branch, until the session loads dsh-customize — the skill whose -# workflow (task worktree, then integrate under the staging lock) the refusal -# points at. Inert everywhere else: another repository, a task worktree nested -# under the protected one, a sibling checkout on a different branch, and any -# workspace outside a dsh source install all pass through untouched. -- id: source-guard - name: '@deepseek-ai/dsh-source-guard' - -# Bash-backed discovery tools (glob/grep): fixed ripgrep commands through the -# local bash executor above — not ctx.fs. Capped results save the complete -# formatted list through the spill backend below (ctx.spillStore, optional). -- id: tool-fs-search - name: '@deepseek-ai/dsh-tool-fs-search' - -# The tool-call timeout enforcer: arms each declared ToolDefinition.timeoutMs -# (the search tools above declare 30s) as a deadline on exec.signal. Without -# it a declared budget is advisory and only the bash executor's own timeout -# backstop applies. -- id: timeout-policy - name: '@deepseek-ai/dsh-timeout-policy' - -# Tool-output spill stack: a local backend that saves oversized tool text under -# a private session-scoped dir, and the tools/post-execute policy that replaces -# an over-budget plain-text result with a preview + the spill locator/retrieval -# hint. A leaf pair after the app (needs ctx.tools). The policy is a no-op until -# a tool returns more than maxInlineBytes of plain text. -- id: spill-local - name: '@deepseek-ai/dsh-spill-local' - -- id: spill-policy - name: '@deepseek-ai/dsh-spill-policy' - config: - maxInlineBytes: 50000 diff --git a/examples/tui-agent/package.json b/examples/tui-agent/package.json deleted file mode 100644 index f45e6746a3..0000000000 --- a/examples/tui-agent/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "tui-agent-example", - "private": true, - "version": "0.0.1", - "type": "module", - "description": "Runnable demo: the coding agent through the full-screen terminal UI" -} diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml deleted file mode 100644 index e615a7ec09..0000000000 --- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml +++ /dev/null @@ -1,53 +0,0 @@ -# Real Loader composition for the keyless conversational PTY test. The app -# bundle supplies the production agent/TUI/user-question stack; only the model -# is scripted so the terminal interaction is deterministic and network-free. -- id: scripted-llm - name: './tui-scripted-llm.ts' - -# Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' - -- id: bash - name: '@deepseek-ai/dsh-bash-local' - -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - -- id: token-meter - name: '@deepseek-ai/dsh-token-meter' - -- id: plan-mode - name: '@deepseek-ai/dsh-plan-mode' - config: - section: 'Stay in plan mode for this scripted TUI test.' - -- id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' - config: - provider: tui-scripted - model: tui-scripted-model - persistenceRoot: './.sessions' - # The smoke's log inspection reads plain `.jsonl`; keep the scripted - # fixture uncompressed like the other snapshot-facing configs. - persistenceCompression: none - workspaceContext: - maxBytes: 65536 - welcome: 'scripted TUI ready.' - persona: 'Scripted model {{model}}.' - ui: - showReasoning: true - -# Model-made session titles, as in the shipped cordis.yml: the scripted adapter -# answers the tool-less title request with a fixed string so the PTY test can -# assert the logged title reaches the terminal window title. -- id: session-title-llm - name: '@deepseek-ai/dsh-session-title-first-message-llm' - config: - targetWords: 5 - targetCjkCharacters: 10 - maxInputBytes: 4096 - maxOutputTokens: 64 - timeoutMs: 10000 diff --git a/examples/web-cordis/cordis.yml b/examples/web-cordis/cordis.yml index 80b598c696..4cd96e396f 100644 --- a/examples/web-cordis/cordis.yml +++ b/examples/web-cordis/cordis.yml @@ -1,18 +1,19 @@ # Opt-in Web composition for inspecting the self-referential Cordis tools. # Temporary Plugin code can reach every injected live capability; treat this # deployment like shell access, not as a security boundary. -- id: base - name: '@cordisjs/plugin-include' +# This file is an OVERLAY over the shipped web composition (`base.cordis.yml` + +# `web.cordis.yml`), not a tree: `dsh web --config` applies it as one more +# sibling patch list at the same include level, so these patches reach base and +# overlay rows alike. A patch replaces the targeted row's whole `config`. + +# AppCLIEntry normally injects the assembly-owned dist path before `dsh web` +# boots; pinning the port here keeps this demo off the default 3080. +- id: webserver config: - path: ../../apps/cli/cordis.yml - patches: - # AppCLIEntry normally injects this assembly-owned path before `dsh web` - # boots; the standalone Cordis launcher needs the equivalent patch here. - - id: webserver - config: - host: 127.0.0.1 - port: 3081 - distIndex: !!js "new URL('./apps/web/dist/index.html', 'file://' + process.cwd() + '/').pathname" - - insert: - - id: tool-cordis - name: '@deepseek-ai/dsh-tool-cordis' + host: 127.0.0.1 + port: 3081 + distIndex: !!js "new URL('./apps/web/dist/index.html', 'file://' + process.cwd() + '/').pathname" + +- insert: + - id: tool-cordis + name: '@deepseek-ai/dsh-tool-cordis' diff --git a/knip.json b/knip.json index 0d616c8388..1db0b5d257 100644 --- a/knip.json +++ b/knip.json @@ -43,7 +43,6 @@ "headless-agent/tests/fixtures/tmux-context-mock-llm.ts", "headless-agent/tests/fixtures/tmux-context-mock-bash.ts", "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", - "tui-agent/tests/fixtures/tui-scripted-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts", @@ -482,15 +481,6 @@ "tests/**/*.ts" ] }, - "packages/examples/tui-demo": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, "packages/examples/cli-demo": { "entry": [ "tests/**/*.spec.ts", @@ -632,8 +622,15 @@ ] }, "apps/cli": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts", + "tests/**/*.snapshot.ts", + "tests/fixtures/tui-scripted-llm.ts" + ], "project": [ - "src/**/*.ts" + "src/**/*.ts", + "tests/**/*.ts" ], "ignoreDependencies": [ "@deepseek-ai/.+", diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index a7d80b3232..02e453415a 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -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/` 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-`, 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/base.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 bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources. diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index ca6b83dadb..f200154104 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -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 type ConfiguredAgentIdentities = Readonly> + +/** + * 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) diff --git a/packages/examples/README.i18n.yaml b/packages/examples/README.i18n.yaml index ceb4a2df7a..ddf815f1b1 100644 --- a/packages/examples/README.i18n.yaml +++ b/packages/examples/README.i18n.yaml @@ -2,5 +2,10 @@ # 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 +<<<<<<< HEAD README.md: c229cef22087ac290bf862d6b3e31fdb533858c4 README.zh.md: 208b9a138506785ea1dd2d83ddfbba29e4b7968e +======= +README.md: d3ad432e71036db0d21f059e52f5d32e58010c42 +README.zh.md: 0218e60df2511974b8eb222e23331c5a70c9df60 +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/packages/examples/README.md b/packages/examples/README.md index c229cef220..d3ad432e71 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -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. diff --git a/packages/examples/README.zh.md b/packages/examples/README.zh.md index 208b9a1385..7ec12eb1c6 100644 --- a/packages/examples/README.zh.md +++ b/packages/examples/README.zh.md @@ -6,13 +6,21 @@ | 包 | npm 名称 | 角色 | |---|---|---| +<<<<<<< HEAD | `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 主干,打包为一个组合包插件,带后备会话标题和选用的持久目标栈 | +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) | `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | 无头单次应用:主干 + JSONL 持久化 + 预创建的 `main` agent,提供文本和 DSH 原生 JSON 输出 | | `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP(Agent 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 客户端使用 | +<<<<<<< HEAD `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 会启动它。 +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) 这些 **不是** 产品 API。它们打包的主干组件位于 [`core/`](../core/README.md),人类/SDK 通道和启动粘合代码位于 [`ui/`](../ui/README.md),自动化传输位于 [`acp/`](../acp/README.md),可替换后端位于各自能力组;演示组合包只选定其中一种具体组合。可以自由替换或 fork。 diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index e08ce5d9ac..48d17b5391 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/README.i18n.yaml @@ -2,5 +2,10 @@ # 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 +<<<<<<< HEAD README.md: 359e7153be2f480ba3fea4b06782acdc9f89ebb9 README.zh.md: acd8c06940b03e90e368314cd725846a2b92b656 +======= +README.md: 6fde5c64052e369541787f90c12c33a7b0b8f9eb +README.zh.md: d5b0e2b50707750132bdeae9d7e56d513b0f40a7 +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 359e7153be..6fde5c6405 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -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. diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index acd8c06940..96a4354b03 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -47,7 +47,11 @@ - **基于模型的会话标题提供方**:组合包挂载带可覆盖示例限制的后备服务(5 个词、40 个后备字节、80 个可接受标题字节);叶节点可以恰好选用一个首消息或全消息 LLM 提供方。 - **bash 执行器**:组合包交付 `tool-bash`(消费方 schema);叶节点提供 `ctx.bash`(`bash-local` 或沙箱化实现)。 - **非本地 skill 提供方**:组合包交付 skill 注册表、本地文件系统提供方和 `skill` 工具;部署可以把嵌入式目录或远程目录等其他提供方作为同级插件添加。 +<<<<<<< HEAD - **前端入口与各应用基础设施**:终端 TUI 或 ACP(Agent Client Protocol)自动化传输,以及 `hmr`。应用包([`dsh-tui-demo`](../tui-demo/README.md)、[`dsh-acp-demo`](../acp-demo/README.md))拥有这些选择。`timer` 位于主干中,因为它是共有组件且不写 stdout;前端入口拥有 stdout,因此留在组合包外。 +======= +- **前端入口与各应用基础设施**:终端 TUI 或 ACP 自动化传输,以及 `hmr`。应用包([`dsh-cli-demo`](../cli-demo/README.md)、[`dsh-acp-demo`](../acp-demo/README.md))拥有这些选择。`timer` 位于主干中,因为它是共有组件且不写 stdout;前端入口拥有 stdout,因此留在组合包外。 +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) 这把[接口/实现/消费方 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) 提升到组合层:组合包拥有共享主干,叶节点拥有后端,应用包拥有前端入口。 diff --git a/packages/examples/tui-demo/README.md b/packages/examples/tui-demo/README.md deleted file mode 100644 index cbe8c79780..0000000000 --- a/packages/examples/tui-demo/README.md +++ /dev/null @@ -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` (launcher boot slot overrides) | 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 | -| `ui` | owner defaults | TUI presentation settings such as reasoning, color, and card height | - -Session identity is launcher-owned rather than configurable: a launcher provides `MAIN_SESSION_ID_KEY` on the boot context, and this app binds both the TUI and the configured agent to that id, loading persisted history only when the launcher also set `resume`. With no such slot the app mints a `main-session-` and creates it fresh. 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`; a launcher may additionally provide `tuiResumeHost` for in-place process handoff and `TUI_GOODBYE_MESSAGE_KEY` for the line printed on exit. - -`persistenceRoot` defaults to project-local `./.sessions`: an app bundle must not assume the user's shared session store. A launcher that wants one store across every cwd states that policy through the `SESSIONS_ROOT_KEY` boot slot (`ctx.provide` before any Loader entry mounts) — the dsh CLI provides its Harness-home root there, so its `/resume` lists sessions from every workspace. Precedence is explicit config, then the launcher slot, then the project-local default. - -## 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 ` 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. diff --git a/packages/examples/tui-demo/package.json b/packages/examples/tui-demo/package.json deleted file mode 100644 index fc59453f0e..0000000000 --- a/packages/examples/tui-demo/package.json +++ /dev/null @@ -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-agent-spine-demo": "^0.0.1", - "@deepseek-ai/dsh-command-goal": "^0.0.1", - "@deepseek-ai/dsh-commands": "^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-persistence-jsonl": "^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-tool-ask-user": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "@deepseek-ai/dsh-tui": "^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-agent-spine-demo": "workspace:^", - "@deepseek-ai/dsh-command-goal": "workspace:^", - "@deepseek-ai/dsh-commands": "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-persistence-jsonl": "workspace:^", - "@deepseek-ai/dsh-session-query": "workspace:^", - "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", - "@deepseek-ai/dsh-session-reference": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tool-ask-user": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-tui": "workspace:^", - "@deepseek-ai/dsh-user-interaction": "workspace:^", - "@deepseek-ai/dsh-workspace-context": "workspace:^", - "cordis": "^4.0.0-rc.7", - "schemastery": "^3.17.0" - } -} diff --git a/packages/examples/tui-demo/src/index.ts b/packages/examples/tui-demo/src/index.ts deleted file mode 100644 index df644090c8..0000000000 --- a/packages/examples/tui-demo/src/index.ts +++ /dev/null @@ -1,169 +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' - -// The bundle's own fallback stays project-local: a plugin must never assume -// the user's shared session store. The dsh launcher's SESSIONS_ROOT_KEY slot -// (opaque here — the CLI resolves it to DSH_HOME/sessions) carries any -// shared-store policy, and explicit config wins over both. -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 - /** - * Directory for JSONL sessions and the derived query index. Precedence: - * this explicit config, then the launcher's opaque `SESSIONS_ROOT_KEY` boot - * slot (the dsh CLI resolves it to `DSH_HOME/sessions`), then a project-local - * `./.sessions` fallback — the bundle itself never assumes a global store. - */ - 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 - /** 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 - /** Generic background-task controls forwarded through agent-spine-demo; set false to omit them. */ - toolTasks?: NonNullable - /** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */ - goals?: agentCore.GoalConfig | false - /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ - workspaceContext: agentCore.Config['workspaceContext'] -} - -export const Config: z = 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, - // No schema default: schemastery would materialize it before composeTuiApp - // runs, shadowing the launcher's SESSIONS_ROOT_KEY slot for a Loader mount. - persistenceRoot: z.string(), - persistenceCompression: JsonlCompressionSchema, - sessionReferences: SessionReferenceService.Config, - welcome: 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]), - 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, taken from the launcher's - * {@link uiTui.MAIN_SESSION_ID_KEY} slot. 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 { - // The launcher, not the deployment config, owns `main`'s session identity: it - // reaches a Loader-mounted bundle only through this context slot. A launcher - // that supplies an id knows whether that session already exists, so it also - // states whether to load persisted history. No launcher means mint one here. - const identity = ctx.get(uiTui.MAIN_SESSION_ID_KEY) - const sessionId = SessionId(identity?.id ?? `main-session-${randomUUID()}`) - const goals = config.goals ?? {} - const persistenceRoot = config.persistenceRoot ?? ctx.get(uiTui.SESSIONS_ROOT_KEY) ?? 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 }, - sessionId, - }) - ctx.plugin(agentCore, { - ...agentCore.pickSpineConfig(config), - goals, - agents: [{ - id: SessionId('main'), - provider: config.provider, - model: config.model, - cwd: process.cwd(), - // `resumeSessionId` requires existing persisted history and rejects a - // missing log, so only a launcher that asked to resume takes that path. - ...identity?.resume === true ? { resumeSessionId: sessionId } : { 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) -} diff --git a/packages/examples/tui-demo/src/invariant.ts b/packages/examples/tui-demo/src/invariant.ts deleted file mode 100644 index 1bb55546bf..0000000000 --- a/packages/examples/tui-demo/src/invariant.ts +++ /dev/null @@ -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 */ diff --git a/packages/examples/tui-demo/tests/tui-agent.spec.ts b/packages/examples/tui-demo/tests/tui-agent.spec.ts deleted file mode 100644 index 88519e33a1..0000000000 --- a/packages/examples/tui-demo/tests/tui-agent.spec.ts +++ /dev/null @@ -1,203 +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 { SessionId } from '@deepseek-ai/dsh-session' -import { MAIN_SESSION_ID_KEY, SESSIONS_ROOT_KEY, type MainSessionIdentity } from '@deepseek-ai/dsh-tui' -import * as tuiAgent from '../src/index.ts' - -interface PluginCall { - readonly name: string - readonly config: unknown -} - -/** - * Record the composed plugin tree. `identity` stands in for the launcher-owned - * {@link MAIN_SESSION_ID_KEY} slot; omitting it means no launcher chose a session. - */ -function recordingContext( - identity?: MainSessionIdentity, - sessionsRoot?: string, -): { readonly ctx: Context; readonly calls: PluginCall[] } { - const calls: PluginCall[] = [] - const ctx = { - plugin(plugin: { name?: string }, config?: unknown) { - calls.push({ name: plugin.name ?? '', config }) - }, - get: (key: string) => key === MAIN_SESSION_ID_KEY ? identity - : key === SESSIONS_ROOT_KEY ? sessionsRoot : undefined, - } 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', - 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', - theme: { color: false }, - maxToolOutputLines: 3, - }) - expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/) - const spineConfig = calls[9]?.config as { - readonly agents: Array> - readonly goals: Record - 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('uses the launcher sessions-root slot through schema-normalized config', () => { - // The Loader normalizes config through the schemastery Config BEFORE apply - // runs. A schema .default() on persistenceRoot would materialize here and - // permanently shadow the launcher slot — the regression this test pins. - const normalized = tuiAgent.Config({ - provider: 'mock', - model: 'mock-model', - workspaceContext: false, - } as never) - expect(normalized.persistenceRoot).toBeUndefined() - - const { ctx, calls } = recordingContext(undefined, '/launcher/sessions') - tuiAgent.composeTuiApp(ctx, normalized) - expect(calls[2]?.config).toMatchObject({ root: '/launcher/sessions' }) - expect(calls[4]?.config).toEqual({ path: join('/launcher/sessions', 'session-query.db') }) - }) - - it('lets an explicit persistenceRoot win over the launcher slot', () => { - const { ctx, calls } = recordingContext(undefined, '/launcher/sessions') - tuiAgent.composeTuiApp(ctx, { - provider: 'mock', - model: 'mock-model', - persistenceRoot: '/explicit/root', - workspaceContext: false, - }) - expect(calls[2]?.config).toEqual({ root: '/explicit/root' }) - }) - - it('loads persisted history for a launcher-selected resume identity', () => { - // The bundle default stays project-local: shared-store policy is the - // launcher's, which patches `persistenceRoot` itself (the dsh CLI does). - const { ctx, calls } = recordingContext({ id: SessionId('persisted-session'), resume: true }) - tuiAgent.composeTuiApp(ctx, { - provider: 'mock', - model: 'mock-model', - workspaceContext: false, - }) - - expect(calls[2]?.config).toEqual({ root: './.sessions' }) - expect(calls[4]?.config).toEqual({ path: join('./.sessions', 'session-query.db') }) - 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> }).agents[0]).toMatchObject({ - id: 'main', - resumeSessionId: 'persisted-session', - }) - }) - - it('creates a launcher-minted identity fresh rather than loading history', () => { - const { ctx, calls } = recordingContext({ id: SessionId('minted-session'), resume: false }) - tuiAgent.composeTuiApp(ctx, { - provider: 'mock', - model: 'mock-model', - workspaceContext: false, - }) - - expect(calls[8]?.config).toEqual({ sessionId: 'minted-session' }) - expect((calls[9]?.config as { agents: Array> }).agents[0]) - .toMatchObject({ id: 'main', sessionId: 'minted-session' }) - }) - - it('mints a fresh session with no launcher slot and routes apply through the same composition', () => { - const { ctx, calls } = recordingContext() - tuiAgent.apply(ctx, { - provider: 'mock', - model: 'mock-model', - 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> }).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 - expect(unwrapped).toBe(tuiAgent) - expect(unwrapped.name).toBe('tui-demo') - expect(unwrapped.Config).toBeDefined() - }) -}) diff --git a/packages/examples/tui-demo/tsconfig.json b/packages/examples/tui-demo/tsconfig.json deleted file mode 100644 index 0ddf3b411c..0000000000 --- a/packages/examples/tui-demo/tsconfig.json +++ /dev/null @@ -1,66 +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": "../../util/paths" - }, - { - "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" - } - ] -} diff --git a/packages/examples/tui-demo/tsdown.config.ts b/packages/examples/tui-demo/tsdown.config.ts deleted file mode 100644 index 1033dc08df..0000000000 --- a/packages/examples/tui-demo/tsdown.config.ts +++ /dev/null @@ -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, -}) diff --git a/packages/guard/source-guard/README.i18n.yaml b/packages/guard/source-guard/README.i18n.yaml index 801d0979b6..79f2f9f423 100644 --- a/packages/guard/source-guard/README.i18n.yaml +++ b/packages/guard/source-guard/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/guard/source-guard/README.md -README.md: f083ef0af53c4d4f7c4a0875837ac3a3c851c54c -README.zh.md: 7c9efa7b8d3aec1a95b62416ef624a887fd48aa0 +README.md: a7406d71c6591f78d12b6c02ec22bc4b0b3d517f +README.zh.md: 916d0513b77e17989730fdf27ef50d21013f4e58 diff --git a/packages/guard/source-guard/README.md b/packages/guard/source-guard/README.md index f083ef0af5..a7406d71c6 100644 --- a/packages/guard/source-guard/README.md +++ b/packages/guard/source-guard/README.md @@ -19,7 +19,7 @@ Every field fails loud at plugin load: an empty `tools` list, a blank `requiredS `protectedCheckout` names a path inside the checkout to guard, and its worktree supplies BOTH protected identities: the repository and the exact branch. Its default is this module's own file, which resolves the checkout the running harness was launched from — the live deployment, whatever its branch is named. Nothing about the branch is configured or pattern-matched, so a maintainer whose staging branch follows no naming convention is protected identically. A harness running from an installed copy resolves a different repository, or none, and therefore guards nothing; the rule is meaningless outside a source checkout. -The shipped TUI composition (`examples/tui-agent/cordis.yml`) loads this plugin with defaults. It is inert for anyone whose workspace is not the launcher's own checkout, so an ordinary project sees no change. +The shipped TUI composition (`apps/cli/base.cordis.yml`) loads this plugin with defaults. It is inert for anyone whose workspace is not the launcher's own checkout, so an ordinary project sees no change. ## Which paths are protected diff --git a/packages/guard/source-guard/README.zh.md b/packages/guard/source-guard/README.zh.md index 7c9efa7b8d..916d0513b7 100644 --- a/packages/guard/source-guard/README.zh.md +++ b/packages/guard/source-guard/README.zh.md @@ -19,7 +19,7 @@ `protectedCheckout` 指定位于待保护检出目录内的一条路径;其 worktree 会提供两项受保护身份:仓库和确切分支。其默认值是本模块自己的文件,由此解析出运行中 harness 启动来源的检出目录——当前运行的部署,无论其分支采用什么名称。分支既无需配置,也不会通过模式匹配,因此 staging 分支不遵循任何命名约定的维护者同样会受到保护。若 harness 从已安装副本运行,则会解析到另一个仓库,或根本解析不到仓库,因此不会保护任何内容;这条规则在源码检出目录之外没有意义。 -已交付的 TUI 组合(`examples/tui-agent/cordis.yml`)会以默认配置加载本插件。若用户的工作区并非启动器自身所在的检出目录,本插件不会生效,因此普通项目不会发生任何变化。 +已交付的 TUI 组合(`apps/cli/base.cordis.yml`)会以默认配置加载本插件。若用户的工作区并非启动器自身所在的检出目录,本插件不会生效,因此普通项目不会发生任何变化。 ## 受保护的路径 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 73b0845370..8375afe22d 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: ca4471454f5be5d3fcba38ce665d4fb3fbd85e74 -README.zh.md: 953539e1198a52b2bf7cdd9ca1b0d263cc2ae6f9 +README.md: c9325d7f0a0feacbc1198d0b737c744ee2075472 +README.zh.md: 1d755bbc2852d6bebed5bd738c19c793657d0afa diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index ca4471454f..0b085e9cb8 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -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/base.cordis.yml`](../../../apps/cli/base.cordis.yml). ## Contract layer (`/api`) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 953539e119..6646945b79 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -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/base.cordis.yml`](../../../apps/cli/base.cordis.yml)。 ## 契约层(`/api`) diff --git a/packages/todo/README.i18n.yaml b/packages/todo/README.i18n.yaml index a7b1f6f529..e726755dcf 100644 --- a/packages/todo/README.i18n.yaml +++ b/packages/todo/README.i18n.yaml @@ -2,5 +2,10 @@ # 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 +<<<<<<< HEAD README.md: 1e5ae9a1583b9e9d3913fcd1dca7ef11a5f391fe README.zh.md: a77f788a41353ea547059864dc7cf73ac5025219 +======= +README.md: 495851a13f70bc8e3cb2dc99da47ab305ca205fa +README.zh.md: abdac592a312cb36d145e7d16e01821561090dcb +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/packages/todo/README.md b/packages/todo/README.md index 1e5ae9a158..495851a13f 100644 --- a/packages/todo/README.md +++ b/packages/todo/README.md @@ -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 front door](../ui/tui) and the host/client runtime render the durable list from session events. diff --git a/packages/todo/README.zh.md b/packages/todo/README.zh.md index a77f788a41..d8d8ffc4db 100644 --- a/packages/todo/README.zh.md +++ b/packages/todo/README.zh.md @@ -8,4 +8,8 @@ |---|---|---| | `tool-todo/` | 面向模型的 `todo_write` 工具;将完整列表写入会话日志(`todo/write`) | (注册到 `ctx.tools`) | +<<<<<<< HEAD 列表存在于事件溯源会话日志中(`SessionEventMap['todo/write']`,由 [`dsh-session`](../core/session) 拥有);本包是追加快照的轻量消费方。[TUI 应用](../examples/tui-demo)等 UI 以及宿主/客户端运行时会根据会话事件渲染该持久化列表。 +======= +列表存在于事件溯源会话日志中(`SessionEventMap['todo/write']`,由 [`dsh-session`](../core/session) 拥有);本包是追加快照的轻量消费方。[TUI 前端入口](../ui/tui)等 UI 以及宿主/客户端运行时会根据会话事件渲染该持久列表。 +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/packages/todo/tool-todo/README.i18n.yaml b/packages/todo/tool-todo/README.i18n.yaml index 603d2cfef0..8b1ac9bcc1 100644 --- a/packages/todo/tool-todo/README.i18n.yaml +++ b/packages/todo/tool-todo/README.i18n.yaml @@ -2,5 +2,10 @@ # 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 +<<<<<<< HEAD README.md: b05ef43e7137dcf5678b1f1ad6d8c00b8a43baef README.zh.md: 88a9f5d69ff52dcedbd8f27a4ace5fde3e0dc5a2 +======= +README.md: f86a0d7331cc822a6568eeb14b89a64abaa23378 +README.zh.md: 778c920198599546890c90d21badf2d7bb0f2551 +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index b05ef43e71..f86a0d7331 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -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 front door](../../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 diff --git a/packages/todo/tool-todo/README.zh.md b/packages/todo/tool-todo/README.zh.md index 88a9f5d69f..26328dde33 100644 --- a/packages/todo/tool-todo/README.zh.md +++ b/packages/todo/tool-todo/README.zh.md @@ -20,7 +20,11 @@ ## 渲染 +<<<<<<< HEAD 规范结果为 `{ 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))。 +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) ## 会话投影 diff --git a/packages/ui/README.i18n.yaml b/packages/ui/README.i18n.yaml index 7a6175f898..06460978a8 100644 --- a/packages/ui/README.i18n.yaml +++ b/packages/ui/README.i18n.yaml @@ -2,5 +2,10 @@ # 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/README.md +<<<<<<< HEAD README.md: f08157d411a018141cdc21c487f81ae198f4de56 README.zh.md: ed4fbf576224a61e680fca337ac5e60829f8a90e +======= +README.md: 307ae14709b59e5c233aa930f20e30c87cdbab69 +README.zh.md: f02f9d902d0b61ad0299fb3fb24b97cf99c56b21 +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/packages/ui/README.md b/packages/ui/README.md index f08157d411..307ae14709 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -19,4 +19,4 @@ A UI integration is a client-driver plugin, not a loop change: it consumes the e `user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with the channel or automation transport that owns the agent. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and interactive app packages provide concrete providers. -The runnable app bundles composed over [`agent-spine-demo`](../examples/agent-spine-demo/README.md) live in [`examples/`](../examples/README.md) (`tui-demo`, `acp-demo`, `jsonrpc-demo`). `acp-demo` and `jsonrpc-demo` own boot bins; the `tui-demo` bundle is booted by the product [`dsh`](../../apps/cli/README.md) CLI. `ui/` keeps the reusable human/SDK channel plugins and shared `app-boot` glue; the automation-only ACP transport lives in [`acp/`](../acp/README.md). Each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools. +The runnable app bundles composed over [`agent-spine-demo`](../examples/agent-spine-demo/README.md) live in [`examples/`](../examples/README.md) (`cli-demo`, `acp-demo`, `jsonrpc-demo`). `acp-demo` and `jsonrpc-demo` own boot bins; The product [`dsh`](../../apps/cli/README.md) CLI uses no bundle: it boots the flat config trees in `apps/cli`. `ui/` keeps the reusable human/SDK channel plugins and shared `app-boot` glue; the automation-only ACP transport lives in [`acp/`](../acp/README.md). Each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools. diff --git a/packages/ui/README.zh.md b/packages/ui/README.zh.md index ed4fbf5762..9ef39eee8d 100644 --- a/packages/ui/README.zh.md +++ b/packages/ui/README.zh.md @@ -19,4 +19,8 @@ UI 集成属于由客户端驱动的插件,而非对循环的修改:它使 `user-approval`、`user-interaction` 和 `tool-ask-user` 位于此处,因为向用户提问是由 UI 支持的产品功能,并不属于无提供方的核心主干。`user-approval` 负责一次性的 `ctx.approval` 决策机制及其策略层级;应答逻辑仍由负责 agent(智能体)的通道或自动化传输层提供。`user-interaction` 保持提供方无关(`ctx.userInteraction`),`tool-ask-user` 是其模型侧消费方,而交互式 app 包提供具体的提供方。 +<<<<<<< HEAD 基于 [`agent-spine-demo`](../examples/agent-spine-demo/README.md) 组合的可运行 app bundle 位于 [`examples/`](../examples/README.md)(`tui-demo`、`acp-demo`、`jsonrpc-demo`)。`acp-demo` 和 `jsonrpc-demo` 各自提供启动 bin;`tui-demo` bundle 则由产品 [`dsh`](../../apps/cli/README.md) CLI(命令行界面)启动。`ui/` 保留可复用的用户/SDK 通道插件和共享 `app-boot` 粘合层;仅供自动化使用的 ACP(Agent Client Protocol)传输层位于 [`acp/`](../acp/README.md)。每个入口都负责自己的 stdout 策略,叶子 `cordis.yml` 则提供后端与可选工具。 +======= +基于 [`agent-spine-demo`](../examples/agent-spine-demo/README.md) 组合的可运行 app bundle 位于 [`examples/`](../examples/README.md)(`cli-demo`、`acp-demo`、`jsonrpc-demo`)。`acp-demo` 和 `jsonrpc-demo` 持有启动 bin;产品 [`dsh`](../../apps/cli/README.md) CLI 不使用 bundle:它启动 `apps/cli` 中的平铺 config tree。`ui/` 保留可复用的用户/SDK 通道插件和共享 `app-boot` 粘合层;仅供自动化使用的 ACP 传输层位于 [`acp/`](../acp/README.md)。每个入口都持有自己的 stdout 策略,叶子 `cordis.yml` 则提供后端与可选工具。 +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 9882d22603..2a2460f18e 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -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 ` 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[] diff --git a/packages/ui/app-boot/tests/config-reload.spec.ts b/packages/ui/app-boot/tests/config-reload.spec.ts index 05ebe80b58..c4f6c48d7f 100644 --- a/packages/ui/app-boot/tests/config-reload.spec.ts +++ b/packages/ui/app-boot/tests/config-reload.spec.ts @@ -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() + } + }) +}) diff --git a/packages/ui/tui/src/chat/resume.ts b/packages/ui/tui/src/chat/resume.ts index 9521bd7368..6e90101bb8 100644 --- a/packages/ui/tui/src/chat/resume.ts +++ b/packages/ui/tui/src/chat/resume.ts @@ -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. */ @@ -75,8 +81,9 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro } } 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() + if (readQuery === undefined) throw new Error('session query is unavailable') + snapshot = await readQuery.readSession(record.header.id) } return summarizeResumeCandidate( record, @@ -105,10 +112,11 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro */ 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() + if (query === undefined) throw new Error('Resume is unavailable: session query is not mounted.') 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 +185,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. diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index d1ba37e61d..78c3505db9 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -306,7 +306,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) @@ -853,7 +852,9 @@ export function createTuiChat( resolved, palette, overlayManager, - sessionQuery, + // Optional and independently mounted: read at each use so config row order + // cannot decide whether /resume works. + sessionQuery: () => ctx.get('sessionQuery'), ui, editor, appendNotice, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3be18db21..9355d06117 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -396,6 +396,9 @@ importers: '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 + node-pty: + specifier: 1.1.0 + version: 1.1.0 apps/web: dependencies: @@ -454,9 +457,18 @@ importers: '@cordisjs/plugin-include': specifier: workspace:* version: link:../vendor/include + '@cordisjs/plugin-timer': + specifier: workspace:* + version: link:../vendor/timer '@deepseek-ai/dsh-acp-demo': specifier: workspace:* version: link:../packages/examples/acp-demo + '@deepseek-ai/dsh-agent': + specifier: workspace:* + version: link:../packages/core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:* + version: link:../packages/core/agent-loop '@deepseek-ai/dsh-agent-spine-demo': specifier: workspace:* version: link:../packages/examples/agent-spine-demo @@ -478,6 +490,12 @@ importers: '@deepseek-ai/dsh-code-runtime-worker': specifier: workspace:* version: link:../packages/code-runtime/code-runtime-worker + '@deepseek-ai/dsh-command-goal': + specifier: workspace:* + version: link:../packages/goal/command-goal + '@deepseek-ai/dsh-commands': + specifier: workspace:* + version: link:../packages/ui/commands '@deepseek-ai/dsh-compact-basic': specifier: workspace:* version: link:../packages/compact/compact-basic @@ -505,6 +523,9 @@ importers: '@deepseek-ai/dsh-hooks-codex': specifier: workspace:* version: link:../packages/hooks/hooks-codex + '@deepseek-ai/dsh-invariants': + specifier: workspace:* + version: link:../packages/support/invariants '@deepseek-ai/dsh-jsonrpc': specifier: workspace:* version: link:../packages/ui/jsonrpc @@ -547,6 +568,12 @@ importers: '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../packages/sandbox/sandbox-policy + '@deepseek-ai/dsh-scope': + specifier: workspace:* + version: link:../packages/core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:* + version: link:../packages/core/session '@deepseek-ai/dsh-session-checkpoint-policy': specifier: workspace:* version: link:../packages/session-persistence/session-checkpoint-policy @@ -559,12 +586,24 @@ importers: '@deepseek-ai/dsh-session-query-sqlite': specifier: workspace:* version: link:../packages/session-query/session-query-sqlite + '@deepseek-ai/dsh-session-reference': + specifier: workspace:* + version: link:../packages/context/session-reference '@deepseek-ai/dsh-session-telemetry-otel': specifier: workspace:* version: link:../packages/telemetry/session-telemetry-otel + '@deepseek-ai/dsh-session-title': + specifier: workspace:* + version: link:../packages/session-title/session-title '@deepseek-ai/dsh-session-title-first-message-llm': specifier: workspace:* version: link:../packages/session-title/session-title-first-message-llm + '@deepseek-ai/dsh-skill': + specifier: workspace:* + version: link:../packages/skill/skill + '@deepseek-ai/dsh-skill-local': + specifier: workspace:* + version: link:../packages/skill/skill-local '@deepseek-ai/dsh-source-guard': specifier: workspace:* version: link:../packages/guard/source-guard @@ -592,6 +631,9 @@ importers: '@deepseek-ai/dsh-subprocess-local': specifier: workspace:* version: link:../packages/subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:* + version: link:../packages/core/system-prompt '@deepseek-ai/dsh-tasks-local': specifier: workspace:* version: link:../packages/tasks/tasks-local @@ -610,6 +652,9 @@ importers: '@deepseek-ai/dsh-tool-ask-user': specifier: workspace:* version: link:../packages/ui/tool-ask-user + '@deepseek-ai/dsh-tool-bash': + specifier: workspace:* + version: link:../packages/bash/tool-bash '@deepseek-ai/dsh-tool-cordis': specifier: workspace:* version: link:../packages/cordis/tool-cordis @@ -634,9 +679,15 @@ importers: '@deepseek-ai/dsh-tool-session-query': specifier: workspace:* version: link:../packages/session-query/tool-session-query + '@deepseek-ai/dsh-tool-skill': + specifier: workspace:* + version: link:../packages/skill/tool-skill '@deepseek-ai/dsh-tool-subagent': specifier: workspace:* version: link:../packages/subagent/tool-subagent + '@deepseek-ai/dsh-tool-tasks': + specifier: workspace:* + version: link:../packages/tasks/tool-tasks '@deepseek-ai/dsh-tool-todo': specifier: workspace:* version: link:../packages/todo/tool-todo @@ -649,12 +700,15 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../packages/core/tools - '@deepseek-ai/dsh-tui-demo': + '@deepseek-ai/dsh-tui': specifier: workspace:* - version: link:../packages/examples/tui-demo + version: link:../packages/ui/tui '@deepseek-ai/dsh-user-approval': specifier: workspace:* version: link:../packages/ui/user-approval + '@deepseek-ai/dsh-user-interaction': + specifier: workspace:* + version: link:../packages/ui/user-interaction '@deepseek-ai/dsh-web': specifier: workspace:* version: link:../packages/web/web @@ -664,10 +718,9 @@ importers: '@deepseek-ai/dsh-workflow-workerthread': specifier: workspace:* version: link:../packages/workflow/workflow-workerthread - devDependencies: - node-pty: - specifier: 1.1.0 - version: 1.1.0 + '@deepseek-ai/dsh-workspace-context': + specifier: workspace:* + version: link:../packages/context/workspace-context packages/acp/acp: dependencies: @@ -2383,75 +2436,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/examples/tui-demo: - devDependencies: - '@cordisjs/plugin-loader': - specifier: workspace:^ - version: link:../../../vendor/loader - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../../core/agent-loop - '@deepseek-ai/dsh-agent-spine-demo': - specifier: workspace:^ - version: link:../agent-spine-demo - '@deepseek-ai/dsh-command-goal': - specifier: workspace:^ - version: link:../../goal/command-goal - '@deepseek-ai/dsh-commands': - specifier: workspace:^ - version: link:../../ui/commands - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-session-checkpoint-policy': - specifier: workspace:^ - version: link:../../session-persistence/session-checkpoint-policy - '@deepseek-ai/dsh-session-persistence-jsonl': - specifier: workspace:^ - version: link:../../session-persistence/session-persistence-jsonl - '@deepseek-ai/dsh-session-query': - specifier: workspace:^ - version: link:../../session-query/session-query - '@deepseek-ai/dsh-session-query-sqlite': - specifier: workspace:^ - version: link:../../session-query/session-query-sqlite - '@deepseek-ai/dsh-session-reference': - specifier: workspace:^ - version: link:../../context/session-reference - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-tool-ask-user': - specifier: workspace:^ - version: link:../../ui/tool-ask-user - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - '@deepseek-ai/dsh-tui': - specifier: workspace:^ - version: link:../../ui/tui - '@deepseek-ai/dsh-user-interaction': - specifier: workspace:^ - version: link:../../ui/user-interaction - '@deepseek-ai/dsh-workspace-context': - specifier: workspace:^ - version: link:../../context/workspace-context - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) - schemastery: - specifier: ^3.17.0 - version: 3.18.0 - packages/fs/fs: devDependencies: '@deepseek-ai/dsh-brand': diff --git a/scripts/demo-code-mode.mjs b/scripts/demo-code-mode.mjs index 994bea32d0..5a98fdb5e8 100644 --- a/scripts/demo-code-mode.mjs +++ b/scripts/demo-code-mode.mjs @@ -12,7 +12,7 @@ const UIS = new Map([ 'tsx/esm', 'apps/cli/src/bin.ts', '--config', - 'examples/tui-agent/code-mode.cordis.yml', + 'examples/code-mode/cordis.yml', ]], ['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']], ]) diff --git a/scripts/demo-cordis.mjs b/scripts/demo-cordis.mjs index 94bbea1332..64fbe0e72d 100644 --- a/scripts/demo-cordis.mjs +++ b/scripts/demo-cordis.mjs @@ -1,21 +1,19 @@ /** - * Boot the self-referential Cordis tools under TUI, Web, or ACP, defaulting - * to TUI. This is a repository demo wrapper, not a product CLI feature. + * Boot the self-referential Cordis tools under Web or ACP, defaulting to Web. This is a repository demo wrapper, not a product CLI feature. */ import { spawn } from 'node:child_process' const SURFACES = new Map([ - ['tui', ['--import', 'tsx', 'apps/cli/src/bin.ts', '--config', 'examples/cordis-agent/cordis.yml']], - // `dsh web` does not accept alternate configs yet. The TUI config escape - // hatch still boots this browser-only tree; the config owns port 3081. - ['web', ['--import', 'tsx', 'apps/cli/src/bin.ts', '--config', 'examples/web-cordis/cordis.yml']], + // The browser surface with the cordis toolset layered on: `dsh web --config` + // applies this overlay over the shipped web composition; it owns port 3081. + ['web', ['--import', 'tsx', 'apps/cli/src/bin.ts', 'web', '--config', 'examples/web-cordis/cordis.yml']], ['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/cordis-tools.cordis.yml']], ]) -const surface = process.argv[2] ?? 'tui' +const surface = process.argv[2] ?? 'web' const args = SURFACES.get(surface) if (args === undefined || process.argv.length > 3) { - console.error('usage: pnpm run demo:cordis [tui|web|acp]') + console.error('usage: pnpm run demo:cordis [web|acp]') process.exit(2) } diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 6df0c9acee..c836289049 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -282,7 +282,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'agent', title: 'Agent service', mode: 'core', - consumers: ['agent-loop', 'acp', 'cli-demo', 'subagent-inprocess', 'tui-demo'], + consumers: ['agent-loop', 'acp', 'cli-demo', 'subagent-inprocess'], note: 'Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation.', }, { @@ -603,10 +603,10 @@ function stripYamlScalar(value: string): string { const APP_EXAMPLES = [ { id: 'tui', - rel: 'examples/tui-agent/composition.md', - title: 'TUI Agent App Composition', - label: 'examples/tui-agent', - config: 'examples/tui-agent/cordis.yml', + rel: 'apps/cli/composition.md', + title: 'dsh TUI Composition', + label: 'apps/cli (dsh)', + config: 'apps/cli/base.cordis.yml', summary: 'The TUI agent combines the real DeepSeek adapter, coding tools, compaction, subagents, and workflows with the full-screen terminal app package.', }, { @@ -617,14 +617,6 @@ const APP_EXAMPLES = [ config: 'examples/headless-agent/cordis.yml', summary: 'The headless demo combines the real DeepSeek adapter and coding capabilities with the one-shot app package, format-pure stdout, and one fresh persisted top-level session.', }, - { - id: 'cordis', - rel: 'examples/cordis-agent/composition.md', - title: 'Cordis Agent App Composition', - label: 'examples/cordis-agent', - config: 'examples/cordis-agent/cordis.yml', - summary: 'The self-referential demo puts @deepseek-ai/dsh-tool-cordis on the coding spine, letting the agent inspect its current-process runtime and mount or unmount in-memory temporary Plugins.', - }, { id: 'acp', rel: 'examples/acp-agent/composition.md', @@ -642,9 +634,7 @@ function renderAppExpansion(lines: string[], appNode: string, pluginName: string const jsonl = nodeId('bundle', 'jsonl') lines.push(` ${appNode} --> ${agentCore}["@deepseek-ai/dsh-agent-spine-demo"]`) lines.push(` ${appNode} --> ${jsonl}["@deepseek-ai/dsh-session-persistence-jsonl"]`) - if (pluginName === '@deepseek-ai/dsh-tui-demo') { - lines.push(` ${appNode} --> ${nodeId('frontdoor', 'tui')}["@deepseek-ai/dsh-tui
pre-created main agent"]`) - } else if (pluginName === '@deepseek-ai/dsh-cli-demo') { + if (pluginName === '@deepseek-ai/dsh-cli-demo') { lines.push(` ${appNode} --> ${nodeId('frontdoor', 'cli')}["one-shot driver
format-pure stdout
fresh top-level agent"]`) } else if (pluginName === '@deepseek-ai/dsh-acp-demo') { lines.push(` ${appNode} --> ${nodeId('frontdoor', 'acp')}["@deepseek-ai/dsh-acp
automation-only JSON-RPC stdio
fresh sessions created by client"]`) @@ -672,7 +662,7 @@ function renderAppComposition(example: AppExample): string { const pluginNode = nodeId(`plugin_${example.id}`, plugin.id) lines.push(` ${pluginNode}["${escLabel(plugin.id)}
${escLabel(plugin.name)}"]`) lines.push(` cfg --> ${pluginNode}`) - if (plugin.name === '@deepseek-ai/dsh-tui-demo' || plugin.name === '@deepseek-ai/dsh-cli-demo' || plugin.name === '@deepseek-ai/dsh-acp-demo') { + if (plugin.name === '@deepseek-ai/dsh-cli-demo' || plugin.name === '@deepseek-ai/dsh-acp-demo') { renderAppExpansion(lines, pluginNode, plugin.name) } } @@ -1184,8 +1174,7 @@ function renderIndex(docs: GraphDoc[]): string { const labels: Record = { 'docs/capability-seams.md': 'capability seams and core services', 'examples/headless-agent/composition.md': 'headless-agent app composition', - 'examples/tui-agent/composition.md': 'tui-agent app composition', - 'examples/cordis-agent/composition.md': 'cordis-agent app composition', + 'apps/cli/composition.md': 'dsh TUI composition', 'examples/acp-agent/composition.md': 'acp-agent app composition', 'docs/event-producer-consumer.md': 'event producer/consumer matrix', 'docs/agent-lifecycle.md': 'agent turn and step lifecycle', @@ -1194,8 +1183,7 @@ function renderIndex(docs: GraphDoc[]): string { const modes: Record = { 'docs/capability-seams.md': 'hybrid generated', 'examples/headless-agent/composition.md': 'hybrid generated', - 'examples/tui-agent/composition.md': 'hybrid generated', - 'examples/cordis-agent/composition.md': 'hybrid generated', + 'apps/cli/composition.md': 'hybrid generated', 'examples/acp-agent/composition.md': 'hybrid generated', 'docs/event-producer-consumer.md': 'hybrid generated', 'docs/agent-lifecycle.md': 'curated', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 7bdc8b68a8..663c2763c9 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -215,7 +215,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolCordis) }, note: - 'Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes.', + 'Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes.', }, { pkg: '@deepseek-ai/dsh-tool-fs', @@ -348,7 +348,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolSubagent, { provider: 'mock' }) }, note: - 'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`.', + 'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `apps/cli/base.cordis.yml` and `examples/acp-agent/cordis.yml`.', }, { pkg: '@deepseek-ai/dsh-tool-tasks', diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index d10a9ccba7..26f633b47e 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -526,7 +526,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { '--config', 'vitest.e2e.config.ts', 'examples/headless-agent/tests/keyless-smoke.e2e.ts', - 'examples/tui-agent/tests/tui-keyless-smoke.e2e.ts', + 'apps/cli/tests/tui-keyless-smoke.e2e.ts', 'packages/examples/cli-demo/tests/built-bin.e2e.ts', 'packages/examples/acp-demo/tests/built-bin.e2e.ts', 'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts', diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index d26d2e8d22..5cfb198296 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -16,11 +16,11 @@ }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`. When Git seeds a new worktree with another registered worktree's marker-backed hook path, the wrapper replaces that copied value with the new worktree's own path; command-scoped and other worktree-scoped paths must be integrated or removed explicitly.\n\nBefore enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files.\n\nAfter moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat 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.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `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 |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `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 |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost 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:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- 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.\n\nStatic 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).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe 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.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis-agent demo can inspect and modify its live plugin runtime and needs the same credentials:\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths must be integrated or removed explicitly.\n\nBefore enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files.\n\nAfter moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat 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.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `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 |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `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 |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost 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:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- 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.\n\nStatic 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).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe 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.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`。当 Git 使用另一个已注册 worktree 中由所有权标记佐证的钩子路径初始化新 worktree 时,包装层会将这个复制值替换为新 worktree 自有的路径;命令作用域和其他 worktree 作用域的路径必须显式集成或移除。\n\n启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。\n\n检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 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)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis-agent 演示可以检查并修改其实时插件运行时,并需要相同的凭证:\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`;命令作用域和 worktree 作用域的自定义路径必须显式集成或移除。\n\n启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。\n\n检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 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)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" }, { "role": "user", diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index 66d6e0f2a3..b0f7b06eb6 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -124,7 +124,7 @@ function validateExampleResolution(): string[] { function validateAppResolution(): string[] { const dependencies = readManifest('apps/cli/package.json').dependencies ?? {} - const references = pluginReferences.filter(reference => reference.file === 'apps/cli/cordis.yml') + const references = pluginReferences.filter(reference => reference.file === 'apps/cli/base.cordis.yml') return missingPluginDependencies(references, dependencies, 'apps/cli/package.json') } diff --git a/tsconfig.base.json b/tsconfig.base.json index 62033becd0..ba1f88779c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -57,6 +57,7 @@ "@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"], "@deepseek-ai/dsh-user-approval/types": ["./packages/ui/user-approval/src/types.ts"], "@deepseek-ai/dsh-user-interaction/types": ["./packages/ui/user-interaction/src/types.ts"], + "@deepseek-ai/dsh-tui/prompt": ["./packages/ui/tui/src/prompt.ts"], "@deepseek-ai/dsh-agent/invariant": ["./packages/core/agent/src/invariant.ts"], "@deepseek-ai/dsh-scope/invariant": ["./packages/core/scope/src/invariant.ts"], "@deepseek-ai/dsh-agent-loop/invariant": ["./packages/core/agent-loop/src/invariant.ts"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 71157bca2a..51ed1cdc3b 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -144,7 +144,6 @@ { "path": "./packages/ui/jsonrpc" }, { "path": "./packages/examples/jsonrpc-demo" }, { "path": "./packages/ui/tui" }, - { "path": "./packages/examples/tui-demo" }, { "path": "./packages/support/llm-replay" }, { "path": "./packages/support/acp-snapshot" }, { "path": "./packages/support/loader-smoke" }, diff --git a/vendor/README.md b/vendor/README.md index a7e3183841..28277733c9 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -37,7 +37,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. 6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. -8. **`include/src/index.ts` hot-reload hardening**: `refresh()` awaits the full read-and-update and catches failures (logging a warning and keeping the last good entry tree) instead of rethrowing — upstream's throw escaped `@cordisjs/plugin-hmr`'s async watcher callback as an unhandled rejection, so one bad `cordis.yml` edit killed a live app. `read()` rejects a non-array parse result (an empty or mid-write truncated file parses to `undefined`, which upstream later crashed on) and commits `content`/`data` only on success, so reverting an edit to the exact last good content reads as "unchanged". `refresh()` and the `internal/update` listener re-apply `config.patches` before `root.update()`, matching initial load; upstream applied patches only in `[Service.init]`, so any config hot-reload silently reverted overlay-patched entries and removed inserted ones. `applyPatches` deep-copies via `structuredClone` instead of mutating the cached parse (repeated application converges; removing a patch reverts), and the veto-style `internal/update` listener persists the incoming config itself (`Fiber.update` only assigns behind `next()`), so later re-reads use the new patches. `[Service.init]` falls back to `initial` only on `ENOENT`; an existing-but-invalid file fails loud with its real parse error instead of "config file not found" (or a silent overwrite). Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. +8. **`include/src/index.ts` hot-reload hardening**: `refresh()` awaits the full read-and-update and catches failures (logging a warning and keeping the last good entry tree) instead of rethrowing — upstream's throw escaped `@cordisjs/plugin-hmr`'s async watcher callback as an unhandled rejection, so one bad `cordis.yml` edit killed a live app. `read()` rejects a non-array parse result (an empty or mid-write truncated file parses to `undefined`, which upstream later crashed on) and commits `content`/`data` only on success, so reverting an edit to the exact last good content reads as "unchanged". `refresh()` and the `internal/update` listener re-apply `config.patches` before `root.update()`, matching initial load; upstream applied patches only in `[Service.init]`, so any config hot-reload silently reverted overlay-patched entries and removed inserted ones. `applyPatches` deep-copies via `structuredClone` instead of mutating the cached parse (repeated application converges; removing a patch reverts), and the veto-style `internal/update` listener persists the incoming config itself (`Fiber.update` only assigns behind `next()`), so later re-reads use the new patches. `[Service.init]` falls back to `initial` only on `ENOENT`; an existing-but-invalid file fails loud with its real parse error instead of "config file not found" (or a silent overwrite). `applyPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. 9. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. ## Sync procedure diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 2ca8fb41ad..29f6a3a951 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -163,6 +163,12 @@ export class Include extends EntryTree { } else { data.push(...insert) } + // Index what this patch added so a LATER patch in the same list can + // target it. Patch lists compose one layer per source (surface overlay, + // then `--config`, then the user's), and a layer must be able to + // configure or disable a row an earlier layer inserted; without this, + // inserted rows were silently unpatchable. + buildMap(insert) continue } diff --git a/vitest.snapshot.config.ts b/vitest.snapshot.config.ts index 2ccec62702..b14422d2f8 100644 --- a/vitest.snapshot.config.ts +++ b/vitest.snapshot.config.ts @@ -48,6 +48,8 @@ export default defineConfig({ // mode remains the zero-build path, while lib mode requires a prior build. ...(process.env.DSH_EXAMPLE_MODE === 'lib' ? ['apps/web/tests/**/*.snapshot.ts'] : []), 'examples/*/tests/**/*.snapshot.ts', + // The shipped TUI's terminal-journey scenarios moved here with its config. + 'apps/cli/tests/**/*.snapshot.ts', 'packages/sdk/*/tests/**/*.snapshot.ts', 'packages/ui/tui/tests/**/*.snapshot.ts', ], From 10ff76de9c8f57238106b21ab04f5c92cb3a9c71 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 14:05:58 +0800 Subject: [PATCH 04/77] fix(cli): declare every plugin the shipped config trees name The Loader resolves each config row against the composing app, so moving the TUI composition from examples/ into apps/cli left eleven rows unresolvable from the built bin: `node apps/cli/lib/bin.js` died at boot with "plugin(s) failed to load". Source-mode runs hid it, because tsconfig paths resolve the whole workspace regardless of who declares what. Also declares `dsh-llm-pi-ai` and `dsh-tmux-context`, which a personal `~/.dsh/config.yaml` inserts to swap the model adapter. Those previously resolved only because the config lived under examples/, whose package.json declares the union of every leaf's plugins. Caught by running the built bin from a scratch directory, which is how a user actually starts it. --- apps/cli/package.json | 28 +++--- pnpm-lock.yaml | 205 ++++++++++-------------------------------- 2 files changed, 67 insertions(+), 166 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index c815cf037a..3c61f474aa 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -14,13 +14,14 @@ ], "license": "BSD-3-Clause", "dependencies": { + "@cordisjs/plugin-hmr": "workspace:*", "@cordisjs/plugin-include": "workspace:*", "@cordisjs/plugin-loader": "workspace:*", "@cordisjs/plugin-timer": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", - "@deepseek-ai/dsh-bash-sandbox": "workspace:^", + "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -32,7 +33,6 @@ "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-model": "workspace:^", "@deepseek-ai/dsh-client-ui-models": "workspace:^", - "@deepseek-ai/dsh-client-ui-permission": "workspace:^", "@deepseek-ai/dsh-client-ui-plan": "workspace:^", "@deepseek-ai/dsh-client-ui-question": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", @@ -48,28 +48,30 @@ "@deepseek-ai/dsh-command-goal": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compact-basic": "workspace:^", + "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-frontend": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", - "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", + "@deepseek-ai/dsh-helper": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", - "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", - "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", - "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-sandbox-local": "workspace:^", - "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", + "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-registry": "workspace:^", "@deepseek-ai/dsh-session-registry-file": "workspace:^", "@deepseek-ai/dsh-session-registry-live": "workspace:^", @@ -77,6 +79,7 @@ "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", + "@deepseek-ai/dsh-source-guard": "workspace:^", "@deepseek-ai/dsh-spill-local": "workspace:^", "@deepseek-ai/dsh-spill-policy": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", @@ -89,10 +92,14 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-timeout-policy": "workspace:^", + "@deepseek-ai/dsh-tmux-context": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", + "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", + "@deepseek-ai/dsh-tool-goal": "workspace:^", + "@deepseek-ai/dsh-tool-ralph": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", @@ -100,14 +107,15 @@ "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-tui": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", + "@earendil-works/pi-ai": "^0.82.1", "commander": "^15.0.0", "cordis": "^4.0.0-rc.7", - "js-yaml": "^4.2.0" + "js-yaml": "^4.2.0", + "yaml": "^2.9.0" }, "devDependencies": { "@types/js-yaml": "^4.0.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9355d06117..02c6d0fd73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,6 +110,9 @@ importers: apps/cli: dependencies: + '@cordisjs/plugin-hmr': + specifier: workspace:* + version: link:../../vendor/hmr '@cordisjs/plugin-include': specifier: workspace:* version: link:../../vendor/include @@ -128,9 +131,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../packages/ui/app-boot - '@deepseek-ai/dsh-bash-sandbox': + '@deepseek-ai/dsh-bash-local': specifier: workspace:^ - version: link:../../packages/bash/bash-sandbox + version: link:../../packages/bash/bash-local '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../packages/client/connection @@ -164,9 +167,6 @@ importers: '@deepseek-ai/dsh-client-ui-models': specifier: workspace:^ version: link:../../packages/client/ui-models - '@deepseek-ai/dsh-client-ui-permission': - specifier: workspace:^ - version: link:../../packages/client/ui-permission '@deepseek-ai/dsh-client-ui-plan': specifier: workspace:^ version: link:../../packages/client/ui-plan @@ -212,33 +212,36 @@ importers: '@deepseek-ai/dsh-compact-basic': specifier: workspace:^ version: link:../../packages/compact/compact-basic + '@deepseek-ai/dsh-compact-tool-result-prune': + specifier: workspace:^ + version: link:../../packages/compact/compact-tool-result-prune '@deepseek-ai/dsh-frontend': specifier: workspace:^ version: link:../web + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../../packages/fs/fs-local '@deepseek-ai/dsh-fs-policy': specifier: workspace:^ version: link:../../packages/fs/fs-policy - '@deepseek-ai/dsh-fs-sandbox': - specifier: workspace:^ - version: link:../../packages/fs/fs-sandbox '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../packages/goal/goal '@deepseek-ai/dsh-goal-session': specifier: workspace:^ version: link:../../packages/goal/goal-session + '@deepseek-ai/dsh-helper': + specifier: workspace:^ + version: link:../../packages/sdk/helper '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../packages/host/apiproxy - '@deepseek-ai/dsh-host-directory-picker-browse': - specifier: workspace:^ - version: link:../../packages/host/directory-picker-browse - '@deepseek-ai/dsh-host-directory-picker-native': - specifier: workspace:^ - version: link:../../packages/host/directory-picker-native '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../packages/host/webserver + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../packages/support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../packages/llm/llm @@ -254,21 +257,18 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../packages/util/paths - '@deepseek-ai/dsh-permission': - specifier: workspace:^ - version: link:../../packages/ui/permission '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode - '@deepseek-ai/dsh-sandbox-local': + '@deepseek-ai/dsh-scope': specifier: workspace:^ - version: link:../../packages/sandbox/sandbox-local - '@deepseek-ai/dsh-sandbox-policy': - specifier: workspace:^ - version: link:../../packages/sandbox/sandbox-policy + version: link:../../packages/core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../packages/core/session + '@deepseek-ai/dsh-session-checkpoint-policy': + specifier: workspace:^ + version: link:../../packages/session-persistence/session-checkpoint-policy '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../packages/session-persistence/session-persistence-jsonl @@ -278,6 +278,12 @@ importers: '@deepseek-ai/dsh-session-projection-cache': specifier: workspace:^ version: link:../../packages/session-projection/session-projection-cache + '@deepseek-ai/dsh-session-query-sqlite': + specifier: workspace:^ + version: link:../../packages/session-query/session-query-sqlite + '@deepseek-ai/dsh-session-reference': + specifier: workspace:^ + version: link:../../packages/context/session-reference '@deepseek-ai/dsh-session-registry': specifier: workspace:^ version: link:../../packages/session-registry/session-registry @@ -299,6 +305,9 @@ importers: '@deepseek-ai/dsh-skill-local': specifier: workspace:^ version: link:../../packages/skill/skill-local + '@deepseek-ai/dsh-source-guard': + specifier: workspace:^ + version: link:../../packages/guard/source-guard '@deepseek-ai/dsh-spill-local': specifier: workspace:^ version: link:../../packages/spill/spill-local @@ -335,9 +344,15 @@ importers: '@deepseek-ai/dsh-timeout-policy': specifier: workspace:^ version: link:../../packages/timeout/timeout-policy + '@deepseek-ai/dsh-tmux-context': + specifier: workspace:^ + version: link:../../packages/context/tmux-context '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../packages/llm/token-meter + '@deepseek-ai/dsh-tool-ask-user': + specifier: workspace:^ + version: link:../../packages/ui/tool-ask-user '@deepseek-ai/dsh-tool-bash': specifier: workspace:^ version: link:../../packages/bash/tool-bash @@ -347,6 +362,12 @@ importers: '@deepseek-ai/dsh-tool-fs-search': specifier: workspace:^ version: link:../../packages/fs/tool-fs-search + '@deepseek-ai/dsh-tool-goal': + specifier: workspace:^ + version: link:../../packages/goal/tool-goal + '@deepseek-ai/dsh-tool-ralph': + specifier: workspace:^ + version: link:../../packages/workflow/tool-ralph '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill @@ -368,9 +389,6 @@ importers: '@deepseek-ai/dsh-tui': specifier: workspace:^ version: link:../../packages/ui/tui - '@deepseek-ai/dsh-user-approval': - specifier: workspace:^ - version: link:../../packages/ui/user-approval '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../../packages/ui/user-interaction @@ -383,6 +401,9 @@ importers: '@deepseek-ai/dsh-workspace-context': specifier: workspace:^ version: link:../../packages/context/workspace-context + '@earendil-works/pi-ai': + specifier: ^0.82.1 + version: 0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) commander: specifier: ^15.0.0 version: 15.0.0 @@ -392,6 +413,9 @@ importers: js-yaml: specifier: ^4.2.0 version: 4.2.0 + yaml: + specifier: ^2.9.0 + version: 2.9.0 devDependencies: '@types/js-yaml': specifier: ^4.0.9 @@ -916,9 +940,6 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - schemastery: - specifier: ^3.18.0 - version: 3.18.0 devDependencies: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ @@ -1138,9 +1159,6 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - '@deepseek-ai/dsh-permission': - specifier: workspace:^ - version: link:../../ui/permission '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../plan/plan-mode @@ -1292,27 +1310,6 @@ importers: specifier: ^18.2.0 version: 18.3.1 - packages/client/ui-permission: - devDependencies: - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime - '@deepseek-ai/dsh-client-ui-command': - specifier: workspace:^ - version: link:../ui-command - '@deepseek-ai/dsh-client-ui-slash': - specifier: workspace:^ - version: link:../ui-slash - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-permission': - specifier: workspace:^ - version: link:../../ui/permission - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/client/ui-plan: devDependencies: '@deepseek-ai/dsh-client-connection': @@ -2948,15 +2945,9 @@ importers: '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal - '@deepseek-ai/dsh-host-directory-picker': - specifier: workspace:^ - version: link:../directory-picker '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-native-command': - specifier: workspace:^ - version: link:../../util/native-command '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -3004,86 +2995,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/host/directory-picker: - devDependencies: - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - - packages/host/directory-picker-browse: - dependencies: - '@deepseek-ai/dsh-host-directory-picker': - specifier: workspace:^ - version: link:../directory-picker - clsx: - specifier: ^2.0.0 - version: 2.1.1 - schemastery: - specifier: ^3.18.0 - version: 3.18.0 - devDependencies: - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../../client/locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../../client/runtime - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../../client/ui-primitives - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../../client/ui-slots - '@deepseek-ai/dsh-client-ui-workspace': - specifier: workspace:^ - version: link:../../client/ui-workspace - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - react: - specifier: ^18.2.0 - version: 18.3.1 - - packages/host/directory-picker-native: - dependencies: - '@deepseek-ai/dsh-host-directory-picker': - specifier: workspace:^ - version: link:../directory-picker - '@deepseek-ai/dsh-native-command': - specifier: workspace:^ - version: link:../../util/native-command - devDependencies: - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../../client/runtime - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../../client/ui-slots - '@deepseek-ai/dsh-client-ui-workspace': - specifier: workspace:^ - version: link:../../client/ui-workspace - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - react: - specifier: ^18.2.0 - version: 18.3.1 - packages/host/webserver: dependencies: schemastery: @@ -5050,16 +4961,10 @@ importers: schemastery: specifier: ^3.18.0 version: 3.18.0 - zod: - specifier: ^4.4.3 - version: 4.4.3 devDependencies: '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../bash/bash - '@deepseek-ai/dsh-commands': - specifier: workspace:^ - version: link:../commands '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5072,9 +4977,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - '@deepseek-ai/dsh-session-projection': - specifier: workspace:^ - version: link:../../session-projection/session-projection '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../user-approval @@ -5243,15 +5145,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/util/native-command: - devDependencies: - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/util/paths: devDependencies: '@deepseek-ai/dsh-invariants': From 571562788df8e27c84ec00a75805e1bcb772a88c Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 15:03:40 +0800 Subject: [PATCH 05/77] chore: reconcile extracted foundation with current master --- apps/cli/README.i18n.yaml | 4 +- docs/event-producer-consumer.md | 12 +-- docs/user/guide/quickstart.i18n.yaml | 4 +- packages/host/README.md | 2 +- packages/host/README.zh.md | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- pnpm-lock.yaml | 131 ++++++++++++++++++++++++ 7 files changed, 145 insertions(+), 14 deletions(-) diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index ba60b83086..616f396d79 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 8b52c8bd5c5b7b75855e2bfbe28d061c5f3742b0 -README.zh.md: 0df55c52f723e901b090673b8fea663f38cbeaaf +README.md: e5a64f5a7f17d0084e6d030e7faf819d38a1df1d +README.zh.md: 7459f356c3c7d494b4e6e8825a9b4751c3d84d1d diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 141f770748..6048296973 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,7 +21,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:299`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | | `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:387`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:236`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:373`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | @@ -31,9 +31,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | @@ -50,7 +50,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:125`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:102`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:102`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`source-guard`](../packages/guard/source-guard), [`tool-tasks`](../packages/tasks/tool-tasks) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | @@ -65,7 +65,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | | `commands/changed` | `runtime` (`emit`) | `ui-command` | | `connection/reset` | `runtime` (`emit`) | `ui-command` | -| `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | +| `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`source-guard`](../packages/guard/source-guard), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `modules`, `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-settings-general` | diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index 71065d0781..8105617a8d 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/quickstart.md -quickstart.md: 75c0d7096f09092106aca46a5a078673708040e7 -quickstart.zh.md: 0beadb65f32f21d18bb4550eda967ed2b275b10f +quickstart.md: be41d00d0f618d62a0f6ee2350cbc5f43c76d981 +quickstart.zh.md: f592fdb3fb9a71f19a5ca356f586667d0674ea49 diff --git a/packages/host/README.md b/packages/host/README.md index d44770f70b..ec7eda7f44 100644 --- a/packages/host/README.md +++ b/packages/host/README.md @@ -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/base.cordis.yml) serving [`apps/web`](../../apps/web/). All **product** packages. | Package | Role | ctx key | |---|---|---| diff --git a/packages/host/README.zh.md b/packages/host/README.zh.md index 2b6878b08b..3c295d8a84 100644 --- a/packages/host/README.zh.md +++ b/packages/host/README.zh.md @@ -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/base.cordis.yml),它负责服务 [`apps/web`](../../apps/web/)。全部为**产品**包。 | 包 | 角色 | ctx 键 | |---|---|---| diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 8375afe22d..5237550801 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: c9325d7f0a0feacbc1198d0b737c744ee2075472 -README.zh.md: 1d755bbc2852d6bebed5bd738c19c793657d0afa +README.md: 0b085e9cb88fe20d9a493fdd26b9cc98ffe0ad52 +README.zh.md: 6646945b79d6e1e5eb72b713a33a4355ae8bc3f8 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02c6d0fd73..e3666ecb36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -940,6 +940,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + schemastery: + specifier: ^3.18.0 + version: 3.18.0 devDependencies: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ @@ -1159,6 +1162,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-permission': + specifier: workspace:^ + version: link:../../ui/permission '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../plan/plan-mode @@ -1310,6 +1316,27 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-permission: + devDependencies: + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-command': + specifier: workspace:^ + version: link:../ui-command + '@deepseek-ai/dsh-client-ui-slash': + specifier: workspace:^ + version: link:../ui-slash + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-permission': + specifier: workspace:^ + version: link:../../ui/permission + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/client/ui-plan: devDependencies: '@deepseek-ai/dsh-client-connection': @@ -2945,9 +2972,15 @@ importers: '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal + '@deepseek-ai/dsh-host-directory-picker': + specifier: workspace:^ + version: link:../directory-picker '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-native-command': + specifier: workspace:^ + version: link:../../util/native-command '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -2995,6 +3028,86 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/host/directory-picker: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/host/directory-picker-browse: + dependencies: + '@deepseek-ai/dsh-host-directory-picker': + specifier: workspace:^ + version: link:../directory-picker + clsx: + specifier: ^2.0.0 + version: 2.1.1 + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../../client/locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../../client/runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../../client/ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../../client/ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../../client/ui-workspace + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 + + packages/host/directory-picker-native: + dependencies: + '@deepseek-ai/dsh-host-directory-picker': + specifier: workspace:^ + version: link:../directory-picker + '@deepseek-ai/dsh-native-command': + specifier: workspace:^ + version: link:../../util/native-command + devDependencies: + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../../client/runtime + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../../client/ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../../client/ui-workspace + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/host/webserver: dependencies: schemastery: @@ -4961,10 +5074,16 @@ importers: schemastery: specifier: ^3.18.0 version: 3.18.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../bash/bash + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../commands '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -4977,6 +5096,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session-projection/session-projection '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../user-approval @@ -5145,6 +5267,15 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/util/native-command: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/util/paths: devDependencies: '@deepseek-ai/dsh-invariants': From 98086a88d56067fc5878e30e39abc2939f1142c8 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 15:05:23 +0800 Subject: [PATCH 06/77] docs(host): update config-tree link pairing --- packages/host/README.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml index afc0e2a695..80bbcefa62 100644 --- a/packages/host/README.i18n.yaml +++ b/packages/host/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/README.md -README.md: d44770f70be16c12f44b78155089e092a3e9bba0 -README.zh.md: 2b6878b08be6489dcd510a0a0e0f0e833c2a8014 +README.md: ec7eda7f44ee7a2e8716f65aaa8d800b2c5abc5d +README.zh.md: 3c295d8a84a29cfd196f0089434cbd6d0341e08b From a459a918e206ca1ed7314a75e447174f4bc12e7a Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 15:07:07 +0800 Subject: [PATCH 07/77] fix(cli): remove obsolete resolver dependencies --- apps/cli/package.json | 4 +--- pnpm-lock.yaml | 6 ------ 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 3c61f474aa..c463bde1a6 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -111,11 +111,9 @@ "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", - "@earendil-works/pi-ai": "^0.82.1", "commander": "^15.0.0", "cordis": "^4.0.0-rc.7", - "js-yaml": "^4.2.0", - "yaml": "^2.9.0" + "js-yaml": "^4.2.0" }, "devDependencies": { "@types/js-yaml": "^4.0.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3666ecb36..5b543c8227 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -401,9 +401,6 @@ importers: '@deepseek-ai/dsh-workspace-context': specifier: workspace:^ version: link:../../packages/context/workspace-context - '@earendil-works/pi-ai': - specifier: ^0.82.1 - version: 0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) commander: specifier: ^15.0.0 version: 15.0.0 @@ -413,9 +410,6 @@ importers: js-yaml: specifier: ^4.2.0 version: 4.2.0 - yaml: - specifier: ^2.9.0 - version: 2.9.0 devDependencies: '@types/js-yaml': specifier: ^4.0.9 From 8f2f6ef0ac488ea12cce0c5ffd721bd6823910e2 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 15:22:17 +0800 Subject: [PATCH 08/77] refactor(cli): exclude live-session registry surface --- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 4 +- apps/cli/README.zh.md | 4 +- apps/cli/package.json | 3 - apps/cli/src/args.ts | 171 ++++----------------------- apps/cli/src/bin.ts | 20 +--- apps/cli/src/headless.ts | 5 +- apps/cli/src/list-sessions.ts | 101 ---------------- apps/cli/src/register-session.ts | 44 ------- apps/cli/src/tui.ts | 11 +- apps/cli/src/web.ts | 14 +-- apps/cli/tests/args.spec.ts | 39 +----- apps/cli/tests/built-bin.e2e.ts | 55 +-------- apps/cli/tests/list-sessions.spec.ts | 74 ------------ apps/cli/tsconfig.json | 9 -- pnpm-lock.yaml | 9 -- 16 files changed, 42 insertions(+), 525 deletions(-) delete mode 100644 apps/cli/src/list-sessions.ts delete mode 100644 apps/cli/src/register-session.ts delete mode 100644 apps/cli/tests/list-sessions.spec.ts diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 616f396d79..a38bcc2eaa 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: e5a64f5a7f17d0084e6d030e7faf819d38a1df1d -README.zh.md: 7459f356c3c7d494b4e6e8825a9b4751c3d84d1d +README.md: 5c2b3eb77f2606928520c937d96d8f728afb062b +README.zh.md: 927934d7265b8a81518d770e57f2b21249b67e56 diff --git a/apps/cli/README.md b/apps/cli/README.md index 72b168ed98..2c1d373b39 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -2,9 +2,8 @@ English | [中文](README.zh.md) -The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, `dsh meta` boots that same TUI over this harness checkout, `dsh migrate` and `dsh upgrade` boot a fresh guided TUI session whose first turn invokes a bundled skill, `dsh list-sessions` lists the sessions running right now, and `dsh web` serves the browser UI. -Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`), whose `meta` subcommand is the same TUI over this checkout, whose `migrate`/`upgrade` subcommands are option-less guided-session entries, whose `list-sessions` subcommand (alias `ps`) lists live sessions, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `migrate`, `upgrade`, `list-sessions`, `web` — rejects a leaked `--config`/`-p`/`--resume` rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. +Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`), whose `meta` subcommand is the same TUI over this checkout, whose `migrate`/`upgrade` subcommands are option-less guided-session entries, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `migrate`, `upgrade`, `web` — rejects a leaked `--config`/`-p`/`--resume` rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. The TUI surface: @@ -18,7 +17,6 @@ The TUI surface: `dsh migrate` and `dsh upgrade` are guided fresh-session entries over the default TUI surface: each mints a fresh session in the invoking directory and seeds its first turn with a bundled skill (`dsh-migrate` for migrating from another coding agent — opencode, pi, Claude Code, Codex; `dsh-upgrade` for upgrading this checkout), exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection. -`dsh list-sessions` lists the sessions running right now: session id, pid, uptime, workspace, and title, newest first. It is read-only and boots no agent tree — it mounts the [session registry](../../packages/session-registry/session-registry/README.md) alone, so listing is fast and cannot start model work as a side effect. Every surface publishes its sessions into that registry through [`dsh-session-registry-live`](../../packages/session-registry/session-registry-live/README.md), and records whose process is gone are pruned on read, so a crashed session disappears without cleanup. `--json` emits the same records as a machine-readable array; an empty listing prints one line and exits 0. There is no workspace filter: the listing is always every live session, whatever directory it runs in. Only top-level surfaces appear — subagents share or spawn other processes and are deliberately invisible. The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index dc0f73aaba..b4c1e8a913 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -2,9 +2,8 @@ [English](README.md) | 中文 -`dsh` 命令行入口遵循 `apps/` 组装层:`apps/*` 是位于 `packages/*` 库之上的产品组装。直接运行 `dsh` 会启动交互式 TUI 编码 agent(智能体),`dsh -p "task"` 运行一个无头轮次,`dsh meta` 以本 harness checkout 为 workspace 启动同一个 TUI,`dsh migrate` 和 `dsh upgrade` 启动一个全新的引导式 TUI 会话并在首轮调用内置 skill,`dsh list-sessions` 列出此刻正在运行的会话,`dsh web` 则提供浏览器 UI。 -Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`migrate`/`upgrade` 子命令是无选项的引导会话入口,`list-sessions` 子命令(别名 `ps`)列出存活会话,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`migrate`、`upgrade`、`list-sessions`、`web`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 +Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`migrate`/`upgrade` 子命令是无选项的引导会话入口,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`migrate`、`upgrade`、`web`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 TUI 界面: @@ -18,7 +17,6 @@ TUI 界面: `dsh migrate` 与 `dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:各自在调用目录中创建一个全新会话,并以一个内置 skill 播种其首轮(`dsh-migrate` 用于从其他编码 agent 迁移——opencode、pi、Claude Code、Codex;`dsh-upgrade` 用于升级本 checkout),效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。 -`dsh list-sessions` 列出此刻正在运行的会话:会话 id、pid、运行时长、工作区和标题,最新的在前。它是只读的,不启动任何 agent 树——它只挂载[会话注册表](../../packages/session-registry/session-registry/README.md),因此列表既快,也不会作为副作用启动模型工作。每个界面都通过 [`dsh-session-registry-live`](../../packages/session-registry/session-registry-live/README.md) 把自己的会话发布到该注册表,进程已不存在的记录会在读取时被剪除,因此崩溃的会话无需清理便会消失。`--json` 以机器可读的数组形式输出同样的记录;空列表打印一行并以 0 退出。没有工作区过滤:列表始终是全部存活会话,无论它们运行在哪个目录下。只有顶层界面会出现——subagent 共用别的进程,或 spawn 出别的进程,因此被刻意排除在列表之外。 Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 diff --git a/apps/cli/package.json b/apps/cli/package.json index c463bde1a6..4a054fa559 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -72,9 +72,6 @@ "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", - "@deepseek-ai/dsh-session-registry": "workspace:^", - "@deepseek-ai/dsh-session-registry-file": "workspace:^", - "@deepseek-ai/dsh-session-registry-live": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 16cf22eda0..b929dc73f2 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -2,24 +2,19 @@ * Commander adapter for the `dsh` command-line entry: the one place argv is * parsed and routed to a mode. `bin.ts` switches on the returned discriminant * and dynamic-imports that mode's module. One program: the default (no - * subcommand) is the TUI/headless surface with option-only flags; `meta` and - * `web` are real subcommands. Commander owns `--help`/`--version` and parse - * errors — it prints and exits at the point of failure (a domain failure routes through + * subcommand) is the TUI/headless surface with option-only flags; `web` is a + * real subcommand. Commander owns `--help`/`--version` and parse errors — it + * prints and exits at the point of failure (a domain failure routes through * `command.error`), so this returns only a resolved mode. * @module @deepseek-ai/dsh/args */ import { Command, CommanderError } from 'commander' -/** - * Interactive TUI: the default mode. `--config` applies an overlay over the - * shipped composition in place of the personal one, `--config-replace` boots a - * file as the whole tree instead, and `--resume ` rehydrates a session. - */ +/** Interactive TUI: the default mode. `--config` swaps the tree; `--resume ` rehydrates a session. */ interface TuiInvocation { mode: 'tui' config?: string - configReplace?: string resume?: string } @@ -29,39 +24,6 @@ interface HeadlessInvocation { prompt: string } -/** - * Interactive TUI over this harness checkout: `dsh meta`. Identical to - * {@link TuiInvocation} except the workspace is the launcher's own source tree - * rather than the invoking directory. No `--config`: booting a foreign tree - * against the harness workspace is the `--config` case, not this one. - */ -interface MetaInvocation { - mode: 'meta' - resume?: string -} - -/** - * Guided fresh-session entries: `dsh migrate` seeds the first turn with the - * `dsh-migrate` skill, `dsh upgrade` with `dsh-upgrade`. Each always mints a - * fresh session in the invoking directory and takes no options — `--resume`, - * `--config`, and `-p` are rejected as mistyped, so there is nothing to carry. - */ -interface SkillSessionInvocation { - mode: 'migrate' | 'upgrade' -} - -/** - * List live sessions: `dsh list-sessions` (alias `dsh ps`). A read-only surface - * that boots no agent tree — it reads the cross-process session registry and - * exits. `json` selects the machine-readable form over the human table. There - * is no workspace filter: the listing is always every live session, whatever - * directory it runs in. - */ -interface ListSessionsInvocation { - mode: 'list-sessions' - json: boolean -} - /** * Browser UI: `dsh web`. `host`/`port` are present only when the flag was * passed — pass-through overrides with no CLI default and no CLI validation: @@ -74,8 +36,6 @@ interface ListSessionsInvocation { */ interface WebInvocation { mode: 'web' - /** Overlay of loader patches applied over the shipped web composition. */ - config?: string host?: string port?: number dev: boolean @@ -85,17 +45,10 @@ interface WebInvocation { } /** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */ -export type DshInvocation = - | TuiInvocation - | HeadlessInvocation - | MetaInvocation - | SkillSessionInvocation - | ListSessionsInvocation - | WebInvocation +export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation /** Raw web-subcommand options straight from Commander. */ interface WebOptions { - config?: string host?: string port?: string dev?: boolean @@ -112,7 +65,6 @@ interface WebOptions { function resolveWeb(options: WebOptions): WebInvocation { return { mode: 'web', - ...options.config !== undefined && { config: options.config }, ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, dev: options.dev === true, @@ -134,31 +86,21 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc const program = new Command() .name('dsh') .version(version, '-V, --version', 'output the version number') - .description('dsh: DeepSeek Harness — an interactive coding agent for your terminal.\nRun `dsh` with no arguments to start a session in the current directory.') - // The default surface takes no positional task, so `dsh "task"` fails - // commander's arity check with no hint; these examples are where a first - // reader learns the entry points and that a one-shot task rides `-p`. - .addHelpText('after', ` -Examples: - dsh start an interactive session in this directory - dsh -p "run the tests" answer one task, print the result, and exit - dsh --resume continue a past session (list ids with \`dsh ps\`) -`) + .description('dsh: interactive TUI (default), headless task, and browser UI') .exitOverride() // Default surface: option-only (no positional), so `web` can be a real // subcommand without a positional collision. - .option('-p, --prompt ', 'answer this task without the interactive UI, then exit') - .option('--resume ', 'continue a past session by id (list ids with `dsh ps`)') - .option('--config ', 'apply this overlay of loader patches instead of the personal one') - .option('--config-replace ', 'boot this file as the entire tree, ignoring the shipped and personal configuration') - .action((options: { config?: string; configReplace?: string; prompt?: string; resume?: string }) => { + .option('--config ', 'boot an alternate cordis.yml instead of the shipped tree (TUI mode)') + .option('-p, --prompt ', 'run one headless turn for this task, print the result, and exit') + .option('--resume ', 'resume the persisted session with this id (TUI mode)') + .action((options: { config?: string; prompt?: string; resume?: string }) => { if (options.prompt !== undefined) { // A headless prompt owns the invocation; an empty task has nothing to // run, and --config/--resume are TUI inputs that must not silently // vanish from a headless run. if (options.prompt === '') program.error('error: --prompt needs a task') - if (options.config !== undefined || options.configReplace !== undefined || options.resume !== undefined) { - program.error('error: --prompt takes no --config, --config-replace, or --resume') + if (options.config !== undefined || options.resume !== undefined) { + program.error('error: --prompt takes no --config or --resume') } resolved = { mode: 'headless', prompt: options.prompt } return @@ -166,97 +108,30 @@ Examples: // An empty --resume= id would silently start a fresh session downstream // (agent-loop treats '' as no-resume), so a mistyped resume must fail loud. if (options.resume === '') program.error('error: --resume needs a session id') - // The two config flags are mutually exclusive: one layers over the shipped - // tree, the other discards it, so accepting both would silently drop one. - if (options.config !== undefined && options.configReplace !== undefined) { - program.error('error: --config and --config-replace are mutually exclusive') - } resolved = { mode: 'tui', ...options.config !== undefined && { config: options.config }, - ...options.configReplace !== undefined && { configReplace: options.configReplace }, ...options.resume !== undefined && { resume: options.resume }, } }) - // Commander parses the parent (default-surface) options on either side of a - // subcommand into `program.opts()`. For a subcommand that shares none of them, - // a leaked `--config`/`-p`/`--resume` is a mistyped invocation that must fail - // loud rather than silently run and drop the input. - const rejectParentOptions = (command: string): void => { - const parent = program.opts<{ config?: string; configReplace?: string; prompt?: string; resume?: string }>() - if (parent.config !== undefined || parent.configReplace !== undefined - || parent.prompt !== undefined || parent.resume !== undefined) { - program.error(`error: ${command} takes none of --config, -p/--prompt, or --resume`) - } - } - - // Registration order is the rendered help order, so daily use comes first - // and the harness-development surfaces (`web --dev`, `meta`) come last. - // `migrate` and `upgrade` are guided fresh-session entries: they take no - // options and always mint a fresh session, so nothing is left to carry. Each - // description names the outcome, not the skill the first turn invokes. - const guided = { - migrate: 'import settings from another coding agent (Claude Code, Codex, opencode)', - upgrade: 'update this dsh installation to the latest version', - } as const - for (const mode of ['migrate', 'upgrade'] as const) { - program - .command(mode) - .description(guided[mode]) - .action(() => { - rejectParentOptions(mode) - resolved = { mode } - }) - } - - program - .command('list-sessions') - .alias('ps') - .description('list sessions running right now') - .option('--json', 'print the records as a JSON array instead of a table') - .action((options: { json?: boolean }) => { - rejectParentOptions('list-sessions') - resolved = { mode: 'list-sessions', json: options.json === true } - }) - - // Host and port name no default: the CLI passes neither through when the flag - // is absent, so the shipped `cordis.yml` value stands and restating it here - // would duplicate a fact this file does not own. - const web = program.command('web').description('serve the browser UI on the configured host and port') + const web = program.command('web').description('serve the browser UI (host/port default to the shipped config)') web - .option('--config ', 'apply this overlay of loader patches over the shipped configuration') - .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') - .option('--port ', 'listen port; pass 0 to let the OS pick a free one') - .option('--dev', 'developer mode: hot-reload the browser client') - .option('--workspace-root ', 'parent directory for workspaces created from the browser UI') + .option('--host ', 'override the config bind host (127.0.0.1 or 0.0.0.0)') + .option('--port ', 'override the config listen port (0 requests an OS-assigned port)') + .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds') + .option('--workspace-root ', 'parent directory for name-created workspaces') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .action((options: WebOptions) => { - rejectParentOptions('web') - resolved = resolveWeb(options) - }) - - // `--resume` is NOT redeclared here: an option a subcommand shares with its - // parent parses into `program.opts()` and leaves the subcommand's own options - // empty, so redeclaring it would silently drop the id. Commander therefore - // omits it from this subcommand's option list, hence the trailing help text. - program - .command('meta') - .description('work on the dsh source that runs this command, from any directory') - .addHelpText('after', '\nAccepts --resume to resume a persisted session from this checkout.\n') - .action(() => { // Commander parses the parent (default-surface) options on either side of - // the subcommand into `program.opts()`. `meta` accepts only `--resume`, so - // a leaked `--config`/`-p` is a mistyped invocation that must fail loud - // rather than silently be dropped. + // the subcommand into `program.opts()`. `web` shares none of them, so a + // leaked `--config`/`-p`/`--resume` is a mistyped invocation that must + // fail loud rather than silently start the web server and drop it. const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>() - if (parent.config !== undefined || parent.prompt !== undefined) { - program.error('error: meta takes neither --config nor -p/--prompt') + if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) { + program.error('error: web takes none of --config, -p/--prompt, or --resume') } - // Same reason as the default surface: an empty id would start a fresh - // session downstream instead of failing the mistyped resume. - if (parent.resume === '') program.error('error: --resume needs a session id') - resolved = { mode: 'meta', ...parent.resume !== undefined && { resume: parent.resume } } + resolved = resolveWeb(options) }) try { diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index caff90d7f2..88dbece55a 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -30,7 +30,7 @@ const invocation = parseDshArgs(process.argv.slice(2), readVersion()) switch (invocation.mode) { case 'web': { const { runWeb } = await import('./web.ts') - await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts, invocation.config) + await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts) break } case 'headless': { @@ -40,23 +40,7 @@ switch (invocation.mode) { } case 'tui': { const { runTui } = await import('./tui.ts') - await runTui(invocation.config, invocation.resume, undefined, undefined, invocation.configReplace) - break - } - case 'meta': { - const { runMeta } = await import('./tui.ts') - await runMeta(invocation.resume) - break - } - case 'list-sessions': { - const { runListSessions } = await import('./list-sessions.ts') - await runListSessions(invocation.json) - break - } - case 'migrate': - case 'upgrade': { - const { runSkillSession } = await import('./tui.ts') - await runSkillSession(`dsh-${invocation.mode}`) + await runTui(invocation.config, invocation.resume) break } default: diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 7c73623907..d7588ccae4 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -14,7 +14,6 @@ import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api' import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import type { SessionId } from '@deepseek-ai/dsh-session' import { AppCLIEntry } from './app-cli-entry.ts' -import { registerLiveSessions } from './register-session.ts' /** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */ interface TurnOutcome { @@ -76,13 +75,11 @@ async function consumeUntilTurnEnd(frames: AsyncIterable>, export async function runHeadless(task: string): Promise { // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). const entry = new AppCLIEntry({ - configPath: fileURLToPath(new URL('../base.cordis.yml', import.meta.url)), - overlayPath: fileURLToPath(new URL('../web.cordis.yml', import.meta.url)), + configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), dev: false, port: 0, }) const { ctx, port } = await entry.run() - await registerLiveSessions(ctx) const dispose = async (): Promise => { await ctx.fiber.dispose() } // The headless session is web-observable while it runs (same composition). process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`) diff --git a/apps/cli/src/list-sessions.ts b/apps/cli/src/list-sessions.ts deleted file mode 100644 index 08a972f402..0000000000 --- a/apps/cli/src/list-sessions.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * `dsh list-sessions` (alias `dsh ps`) — list the sessions running right now. - * - * A read-only surface: it mounts the session registry alone and never boots an - * agent tree, so listing stays fast and cannot start model work as a side - * effect. Liveness comes from the registry, which prunes records whose process - * is gone, and every displayed field including the title comes from the record, - * so no session log is opened and no backend format is assumed. - * @module @deepseek-ai/dsh/list-sessions - */ - -import { Context } from 'cordis' -import { type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry' -import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file' -import { registryRoot } from './register-session.ts' - -/** Column header text, also the minimum width of each column. */ -const HEADERS = ['SESSION', 'PID', 'UPTIME', 'WORKSPACE', 'TITLE'] as const - -/** Shown when a session has no title yet. */ -const NO_TITLE = '—' - -/** - * Render milliseconds of uptime as a compact human duration. - * @param ms - elapsed milliseconds since the session registered. - * @returns a short duration such as `12s`, `4m`, or `2h14m`. - */ -export function formatUptime(ms: number): string { - const seconds = Math.max(0, Math.floor(ms / 1000)) - if (seconds < 60) return `${String(seconds)}s` - const minutes = Math.floor(seconds / 60) - if (minutes < 60) return `${String(minutes)}m` - const hours = Math.floor(minutes / 60) - const remainder = minutes % 60 - if (hours < 24) return remainder === 0 ? `${String(hours)}h` : `${String(hours)}h${String(remainder)}m` - const days = Math.floor(hours / 24) - const leftoverHours = hours % 24 - return leftoverHours === 0 ? `${String(days)}d` : `${String(days)}d${String(leftoverHours)}h` -} - -/** One fully-resolved listing row, in column order. */ -type Row = readonly [string, string, string, string, string] - -/** - * Build the display rows for a listing, newest session first. - * @param records - the live records to render. - * @param now - the current epoch milliseconds uptime is measured against. - * @returns one row per record, each already stringified per column. - */ -export function buildRows(records: readonly SessionRegistryRecord[], now: number): Row[] { - return [...records] - .sort((left, right) => right.startedAt - left.startedAt) - .map(record => [ - record.sessionId, - String(record.pid), - formatUptime(now - record.startedAt), - record.cwd, - record.title ?? NO_TITLE, - ] as const) -} - -/** - * Render rows as a left-aligned table with a header line. - * - * The last column is never padded, so a long title cannot add trailing - * whitespace to every line. - * @param rows - the rows to render, already stringified. - * @returns the complete table text, newline-terminated. - */ -export function renderTable(rows: readonly Row[]): string { - const widths = HEADERS.map((header, column) => - Math.max(header.length, ...rows.map(row => row[column]?.length ?? 0))) - const line = (cells: readonly string[]): string => - cells.map((cell, column) => column === cells.length - 1 ? cell : cell.padEnd(widths[column] ?? 0)).join(' ').trimEnd() - return [line(HEADERS), ...rows.map(row => line(row))].join('\n') + '\n' -} - -/** - * List live sessions and exit. Prints a table by default, or a JSON array with - * `--json`; an empty listing is a success, not an error. - * @param json - emit the machine-readable JSON array instead of the table. - */ -export async function runListSessions(json: boolean): Promise { - const ctx = new Context() - await ctx.plugin(SessionRegistryFile, { root: registryRoot() }) - const records = await ctx.sessionRegistry.list() - await ctx.fiber.dispose() - - if (json) { - const rows = [...records] - .sort((left, right) => right.startedAt - left.startedAt) - .map(record => ({ ...record, uptimeMs: Date.now() - record.startedAt, title: record.title ?? null })) - process.stdout.write(`${JSON.stringify(rows, undefined, 2)}\n`) - return - } - if (records.length === 0) { - process.stdout.write('no dsh sessions running\n') - return - } - process.stdout.write(renderTable(buildRows(records, Date.now()))) -} diff --git a/apps/cli/src/register-session.ts b/apps/cli/src/register-session.ts deleted file mode 100644 index ff78456e8a..0000000000 --- a/apps/cli/src/register-session.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Mounts the cross-process live-session registry that `dsh list-sessions` reads, plus the - * publisher that keeps it in step with this process's sessions. - * - * Both plugins mount on the booted app's own context, so records share that - * fiber's lifetime: an ordinary exit disposes the fiber and deregisters, while a - * killed process leaves records the next reader prunes by pid. Only top-level - * surfaces a user launches mount this — in-process subagents have no process of - * their own, and out-of-process subagent backends spawn `dsh-jsonrpc-agent` - * rather than this CLI, so neither reaches this path. - * @module @deepseek-ai/dsh/register-session - */ - -import { join } from 'node:path' -import type { Context } from 'cordis' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' -import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file' -import * as sessionRegistryLive from '@deepseek-ai/dsh-session-registry-live' - -/** Registry root under the Harness home, shared by every surface and by `dsh list-sessions`. */ -export const registryRoot = (): string => join(resolveDshHome(), 'run') - -/** - * Publish this process's sessions for the lifetime of `ctx`. - * - * Publication follows session lifecycle rather than a launcher-known id, so one - * path serves every surface identically — the TUI's single session and a - * server's on-demand ones alike — and titles reach the listing as they are - * logged. - * - * Mounting is best-effort: a registry failure must not take down a working agent - * session, because the registry is an observability aid rather than part of the - * agent's contract. Failures warn through the context logger. - * @param ctx - the booted app context whose lifetime the records share. - */ -export async function registerLiveSessions(ctx: Context): Promise { - try { - const scope = ctx.isolate('sessionRegistry') - await scope.plugin(SessionRegistryFile, { root: registryRoot() }) - await scope.plugin(sessionRegistryLive) - } catch (error) { - ctx.logger('dsh').warn('session registry unavailable; `dsh list-sessions` will not list these sessions: %s', String(error)) - } -} diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index c65f01fbc3..f0ef5ddc02 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -33,7 +33,6 @@ import { resolveDshHome, resolveSessionsRoot } from '@deepseek-ai/dsh-paths' import { SessionId } from '@deepseek-ai/dsh-session' import { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop' import type { Context } from 'cordis' -import { registerLiveSessions } from './register-session.ts' import { INITIAL_SKILL_KEY, MAIN_SESSION_ID_KEY, @@ -71,8 +70,7 @@ const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) * shared session-store root, `sessions` under the Harness home. Shared-store * policy is the launcher's alone — the app bundle treats the slot as opaque and * keeps a project-local fallback, so only `dsh` decides that sessions are - * shared across working directories (making `/resume` and `list-sessions` span - * every workspace). + * shared across working directories (making `/resume` span every workspace). * @returns the absolute session-store root this launcher shares. */ export function launcherSessionsRoot(): string { @@ -238,8 +236,8 @@ export async function runTui( hostCtx.provide(MAIN_SESSION_ID_KEY, identity) hostCtx.provide(TUI_GOODBYE_MESSAGE_KEY, goodbye) // Shared-store policy is the launcher's: sessions live in one root under - // the Harness home across every cwd, so /resume and list-sessions see - // every workspace. The bundle treats the slot as opaque. + // the Harness home across every cwd, so /resume sees every workspace. + // The bundle treats the slot as opaque. hostCtx.provide(SESSIONS_ROOT_KEY, launcherSessionsRoot()) // The agent-loop row reads this to bind `main`, and the tui row reads the // same id, so a personal overlay repointing the model route cannot drop @@ -258,8 +256,5 @@ export async function runTui( ) app.current = ctx addHarnessSourceSection(ctx, SOURCE_ROOT) - // Publication follows the store; meta mode already chdir'd, so each session - // reports its own cwd. - await registerLiveSessions(ctx) } /* v8 ignore stop */ diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 24d21a6eb4..69e79ab5d9 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -7,13 +7,9 @@ */ import { fileURLToPath } from 'node:url' -import { resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import { AppCLIEntry } from './app-cli-entry.ts' -import { registerLiveSessions } from './register-session.ts' -// The shared core every `dsh` surface mounts, plus this surface's overlay over it. -const BASE_CONFIG = fileURLToPath(new URL('../base.cordis.yml', import.meta.url)) -const WEB_OVERLAY = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) +const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) // Display-only mirror of the webserver schema's loopback host: the address the // local URL always prints. Not a source of truth — the schema is. @@ -27,8 +23,6 @@ const LOOPBACK_HOST = '127.0.0.1' * @param dev - mount the client HMR driver and watch plugin bundles for rebuilds. * @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback. * @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone. - * @param config - an overlay of loader patches applied over the shipped web - * composition, or `undefined` for none; already parsed from `--config`. */ export async function runWeb( host: string | undefined, @@ -36,12 +30,9 @@ export async function runWeb( dev: boolean, workspaceRoot: string | undefined, trustedHosts: string[] | undefined, - config?: string, ): Promise { const entry = new AppCLIEntry({ - configPath: BASE_CONFIG, - overlayPath: WEB_OVERLAY, - ...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) }, + configPath: CONFIG_PATH, dev, ...host !== undefined && { host }, ...port !== undefined && { port }, @@ -49,7 +40,6 @@ export async function runWeb( ...trustedHosts !== undefined && { trustedHosts }, }) const { ctx, port: boundPort } = await entry.run() - await registerLiveSessions(ctx) let exiting = false const shutdown = (code: number): void => { diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 99569c7cf0..45830eee30 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -24,33 +24,17 @@ function exitCode(argv: string[]): number { afterEach(() => { vi.restoreAllMocks() }) describe('parseDshArgs', () => { - it('routes each mode by its shape: default TUI, -p headless, meta and web subcommands', () => { + it('routes each mode by its shape: default TUI, -p headless, web subcommand', () => { expect(parse([])).toEqual({ mode: 'tui' }) expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' }) expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) - // `meta` accepts `--resume` but does not redeclare it: a shared option parses - // into program.opts() on either side of the subcommand, and redeclaring it - // would leave the subcommand's own options empty and drop the id. - expect(parse(['meta'])).toEqual({ mode: 'meta' }) - expect(parse(['meta', '--resume', 'sess'])).toEqual({ mode: 'meta', resume: 'sess' }) - expect(parse(['--resume', 'sess', 'meta'])).toEqual({ mode: 'meta', resume: 'sess' }) - // Credential setup is option-free: it writes the Harness-home .env, so - // there is nothing for a flag to select. // Bare `web` carries no host/port: the shipped cordis.yml owns the default. expect(parse(['web'])).toEqual({ mode: 'web', dev: false }) // Host/port are unvalidated pass-throughs (the webserver schema gates them // at boot); the adapter only coerces the port string to a number. expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' }) - // Guided fresh-session entries carry nothing: bare mode discriminant only. - expect(parse(['migrate'])).toEqual({ mode: 'migrate' }) - expect(parse(['upgrade'])).toEqual({ mode: 'upgrade' }) - // `list-sessions` has one option and no workspace filter: the listing is always - // global. `ps` is its alias and resolves to the same mode. - expect(parse(['list-sessions'])).toEqual({ mode: 'list-sessions', json: false }) - expect(parse(['list-sessions', '--json'])).toEqual({ mode: 'list-sessions', json: true }) - expect(parse(['ps', '--json'])).toEqual({ mode: 'list-sessions', json: true }) // --trusted-host is variadic and repeatable; authorities pass through unvalidated. expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) .toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) @@ -71,27 +55,6 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '-p', 'task'])).toBe(1) expect(exitCode(['web', '--resume', 's'])).toBe(1) expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1) - // Same rule for credential setup: it shares no option with the default - // surface, so a leaked flag is a typo, not something to ignore. - // `meta` fixes its own config tree and is interactive, so --config/-p are - // rejected; an empty id is swallowed downstream exactly as above. - expect(exitCode(['meta', '--resume='])).toBe(1) - expect(exitCode(['meta', '--config', 'c.yml'])).toBe(1) - expect(exitCode(['meta', '-p', 'task'])).toBe(1) - // `migrate`/`upgrade` take no options: any leaked default-surface flag is a - // mistyped invocation, not a silently-dropped input. - expect(exitCode(['migrate', '--resume', 's'])).toBe(1) - expect(exitCode(['migrate', '--config', 'c.yml'])).toBe(1) - expect(exitCode(['migrate', '-p', 'task'])).toBe(1) - expect(exitCode(['upgrade', '--resume', 's'])).toBe(1) - expect(exitCode(['upgrade', '--config', 'c.yml'])).toBe(1) - expect(exitCode(['-p', 'task', 'upgrade'])).toBe(1) - // `list-sessions`/`ps` is read-only and shares no default-surface option: a leaked flag is a - // mistyped invocation, not a listing with a silently dropped input. - expect(exitCode(['ps', '--resume', 's'])).toBe(1) - expect(exitCode(['list-sessions', '--config', 'c.yml'])).toBe(1) - expect(exitCode(['list-sessions', '-p', 'task'])).toBe(1) - expect(exitCode(['--resume', 's', 'ps'])).toBe(1) }) it('exits 0 for --help (disclosing web) and --version', () => { diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 71c8257b08..1ea7d9f0db 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -1,9 +1,8 @@ -import { existsSync, mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { existsSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { execa } from 'execa' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' /** * Published-entry smoke for the `dsh` bin: run the built `lib/bin.js` under @@ -17,28 +16,19 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' * node_modules, so no external consumer is assembled; missing-config fail-loud * and full-boot coverage for the shared dsh-app-boot glue live in cli-demo's * built-bin suite, and interactive TTY behavior is PTY-covered by - * examples/tui-agent. `dsh list-sessions` is covered here too: it is the one surface that - * boots no agent tree, so the built bin is the whole product path. - * Skips before the bin is built. + * examples/tui-agent. Skips before the bin is built. */ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') -/** - * Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output - * + exit code. `env` isolates the Harness home for surfaces that read it. - */ -async function runBuiltBin( - args: readonly string[] = [], - env: Record = {}, -): Promise<{ stdout: string; code: number; stderr: string }> { - const result = await execa(process.execPath, [dshBin, ...args], { +/** Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output + exit code. */ +async function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> { + const result = await execa(process.execPath, [dshBin], { input: '', timeout: 25_000, killSignal: 'SIGKILL', reject: false, - env, }) if (result.timedOut) { throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) @@ -55,37 +45,4 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', // The refusal happens before any plugin mounts: stdout stays silent. expect(stdout).toBe('') }, 30_000) - - describe('dsh list-sessions', () => { - let home: string - beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-ls-bin-')) }) - afterEach(() => { rmSync(home, { recursive: true, force: true }) }) - - it('reports an empty listing as success, not an error', async () => { - const { stdout, code, stderr } = await runBuiltBin(['list-sessions'], { DSH_HOME: home }) - expect(code).toBe(0) - expect(stdout.trim()).toBe('no dsh sessions running') - expect(stderr).toBe('') - }, 30_000) - - it('emits an empty JSON array for machines', async () => { - const { stdout, code } = await runBuiltBin(['ps', '--json'], { DSH_HOME: home }) - expect(code).toBe(0) - expect(JSON.parse(stdout)).toEqual([]) - }, 30_000) - - it('runs without a TTY, unlike the TUI surface', async () => { - // The listing is read-only and boots no agent tree, so piped stdio — the - // launch the TUI refuses — is a supported way to run it. - const { code, stderr } = await runBuiltBin(['ps'], { DSH_HOME: home }) - expect(code).toBe(0) - expect(stderr).not.toContain('interactive TTYs') - }, 30_000) - - it('rejects a leaked default-surface flag instead of listing', async () => { - const { code, stderr } = await runBuiltBin(['list-sessions', '--resume', 'sess'], { DSH_HOME: home }) - expect(code).not.toBe(0) - expect(stderr).toContain('list-sessions takes none of') - }, 30_000) - }) }) diff --git a/apps/cli/tests/list-sessions.spec.ts b/apps/cli/tests/list-sessions.spec.ts deleted file mode 100644 index a73faf1b20..0000000000 --- a/apps/cli/tests/list-sessions.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Tests for the `dsh list-sessions` presentation layer: uptime formatting, row building - * (newest first, absent-title placeholder) and table alignment without trailing - * padding. Every displayed field comes from the record, so there is no log - * reading to cover here. - */ - -import { describe, expect, it } from 'vitest' -import { SessionId } from '@deepseek-ai/dsh-session' -import { BootId, type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry' -import { buildRows, formatUptime, renderTable } from '../src/list-sessions.ts' - -function record(overrides: Partial = {}): SessionRegistryRecord { - return { - sessionId: SessionId('sess-1'), - pid: 4242, - cwd: '/work/project', - startedAt: 1_000, - bootId: BootId('boot-1'), - ...overrides, - } -} - -describe('formatUptime', () => { - it.each([ - [0, '0s'], - [999, '0s'], - [12_000, '12s'], - [59_999, '59s'], - [60_000, '1m'], - [3_540_000, '59m'], - [3_600_000, '1h'], - [8_040_000, '2h14m'], - [86_400_000, '1d'], - [90_000_000, '1d1h'], - ])('renders %ims as %s', (ms, expected) => { - expect(formatUptime(ms)).toBe(expected) - }) - - it('never renders a negative duration for a clock that moved backwards', () => { - expect(formatUptime(-5_000)).toBe('0s') - }) -}) - -describe('buildRows', () => { - it('orders newest first and marks a missing title', () => { - const rows = buildRows([ - record({ sessionId: SessionId('older'), startedAt: 1_000 }), - record({ sessionId: SessionId('newer'), startedAt: 5_000 }), - ], 65_000) - expect(rows.map(row => row[0])).toEqual(['newer', 'older']) - expect(rows[0]).toEqual(['newer', '4242', '1m', '/work/project', '—']) - }) -}) - -describe('renderTable', () => { - it('aligns columns and leaves no trailing whitespace', () => { - const table = renderTable(buildRows([ - record({ sessionId: SessionId('short'), startedAt: 0, title: 'a title' }), - record({ sessionId: SessionId('a-much-longer-session-id'), startedAt: 1, title: 'a title' }), - ], 1_000)) - const lines = table.split('\n') - expect(lines[0]).toMatch(/^SESSION {18}\s+PID/) - for (const line of lines) expect(line).toBe(line.trimEnd()) - // The header and every row align on the same column starts. - const pidColumn = (line: string): number => line.includes('4242') ? line.indexOf('4242') : line.indexOf('PID') - expect(pidColumn(lines[1] ?? '')).toBe(pidColumn(lines[0] ?? '')) - expect(pidColumn(lines[2] ?? '')).toBe(pidColumn(lines[0] ?? '')) - }) - - it('renders a header even with no rows, so the columns stay discoverable', () => { - expect(renderTable([])).toBe('SESSION PID UPTIME WORKSPACE TITLE\n') - }) -}) diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index ef0a6705ad..ce43c0f31c 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -29,15 +29,6 @@ { "path": "../../packages/util/paths" }, - { - "path": "../../packages/session-registry/session-registry" - }, - { - "path": "../../packages/session-registry/session-registry-file" - }, - { - "path": "../../packages/session-registry/session-registry-live" - }, { "path": "../../packages/client/connection" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b543c8227..e9281011c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -284,15 +284,6 @@ importers: '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../packages/context/session-reference - '@deepseek-ai/dsh-session-registry': - specifier: workspace:^ - version: link:../../packages/session-registry/session-registry - '@deepseek-ai/dsh-session-registry-file': - specifier: workspace:^ - version: link:../../packages/session-registry/session-registry-file - '@deepseek-ai/dsh-session-registry-live': - specifier: workspace:^ - version: link:../../packages/session-registry/session-registry-live '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../packages/session-title/session-title From c1324ee8969b4658dccb3d16057cbf7a913bb957 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 15:28:38 +0800 Subject: [PATCH 09/77] fix(cli): preserve overlays without session registration --- apps/cli/src/args.ts | 152 ++++++++++++++++++++++++++++++------ apps/cli/src/bin.ts | 15 +++- apps/cli/src/headless.ts | 3 +- apps/cli/src/web.ts | 12 ++- apps/cli/tests/args.spec.ts | 28 ++++++- 5 files changed, 179 insertions(+), 31 deletions(-) diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index b929dc73f2..5cdf9d20d4 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -2,19 +2,24 @@ * Commander adapter for the `dsh` command-line entry: the one place argv is * parsed and routed to a mode. `bin.ts` switches on the returned discriminant * and dynamic-imports that mode's module. One program: the default (no - * subcommand) is the TUI/headless surface with option-only flags; `web` is a - * real subcommand. Commander owns `--help`/`--version` and parse errors — it - * prints and exits at the point of failure (a domain failure routes through + * subcommand) is the TUI/headless surface with option-only flags; `meta` and + * `web` are real subcommands. Commander owns `--help`/`--version` and parse + * errors — it prints and exits at the point of failure (a domain failure routes through * `command.error`), so this returns only a resolved mode. * @module @deepseek-ai/dsh/args */ import { Command, CommanderError } from 'commander' -/** Interactive TUI: the default mode. `--config` swaps the tree; `--resume ` rehydrates a session. */ +/** + * Interactive TUI: the default mode. `--config` applies an overlay over the + * shipped composition in place of the personal one, `--config-replace` boots a + * file as the whole tree instead, and `--resume ` rehydrates a session. + */ interface TuiInvocation { mode: 'tui' config?: string + configReplace?: string resume?: string } @@ -24,6 +29,27 @@ interface HeadlessInvocation { prompt: string } +/** + * Interactive TUI over this harness checkout: `dsh meta`. Identical to + * {@link TuiInvocation} except the workspace is the launcher's own source tree + * rather than the invoking directory. No `--config`: booting a foreign tree + * against the harness workspace is the `--config` case, not this one. + */ +interface MetaInvocation { + mode: 'meta' + resume?: string +} + +/** + * Guided fresh-session entries: `dsh migrate` seeds the first turn with the + * `dsh-migrate` skill, `dsh upgrade` with `dsh-upgrade`. Each always mints a + * fresh session in the invoking directory and takes no options — `--resume`, + * `--config`, and `-p` are rejected as mistyped, so there is nothing to carry. + */ +interface SkillSessionInvocation { + mode: 'migrate' | 'upgrade' +} + /** * Browser UI: `dsh web`. `host`/`port` are present only when the flag was * passed — pass-through overrides with no CLI default and no CLI validation: @@ -36,6 +62,8 @@ interface HeadlessInvocation { */ interface WebInvocation { mode: 'web' + /** Overlay of loader patches applied over the shipped web composition. */ + config?: string host?: string port?: number dev: boolean @@ -45,10 +73,16 @@ interface WebInvocation { } /** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */ -export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation +export type DshInvocation = + | TuiInvocation + | HeadlessInvocation + | MetaInvocation + | SkillSessionInvocation + | WebInvocation /** Raw web-subcommand options straight from Commander. */ interface WebOptions { + config?: string host?: string port?: string dev?: boolean @@ -65,6 +99,7 @@ interface WebOptions { function resolveWeb(options: WebOptions): WebInvocation { return { mode: 'web', + ...options.config !== undefined && { config: options.config }, ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, dev: options.dev === true, @@ -86,21 +121,31 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc const program = new Command() .name('dsh') .version(version, '-V, --version', 'output the version number') - .description('dsh: interactive TUI (default), headless task, and browser UI') + .description('dsh: DeepSeek Harness — an interactive coding agent for your terminal.\nRun `dsh` with no arguments to start a session in the current directory.') + // The default surface takes no positional task, so `dsh "task"` fails + // commander's arity check with no hint; these examples are where a first + // reader learns the entry points and that a one-shot task rides `-p`. + .addHelpText('after', ` +Examples: + dsh start an interactive session in this directory + dsh -p "run the tests" answer one task, print the result, and exit + dsh --resume continue a past session +`) .exitOverride() // Default surface: option-only (no positional), so `web` can be a real // subcommand without a positional collision. - .option('--config ', 'boot an alternate cordis.yml instead of the shipped tree (TUI mode)') - .option('-p, --prompt ', 'run one headless turn for this task, print the result, and exit') - .option('--resume ', 'resume the persisted session with this id (TUI mode)') - .action((options: { config?: string; prompt?: string; resume?: string }) => { + .option('-p, --prompt ', 'answer this task without the interactive UI, then exit') + .option('--resume ', 'continue a past session by id') + .option('--config ', 'apply this overlay of loader patches instead of the personal one') + .option('--config-replace ', 'boot this file as the entire tree, ignoring the shipped and personal configuration') + .action((options: { config?: string; configReplace?: string; prompt?: string; resume?: string }) => { if (options.prompt !== undefined) { // A headless prompt owns the invocation; an empty task has nothing to // run, and --config/--resume are TUI inputs that must not silently // vanish from a headless run. if (options.prompt === '') program.error('error: --prompt needs a task') - if (options.config !== undefined || options.resume !== undefined) { - program.error('error: --prompt takes no --config or --resume') + if (options.config !== undefined || options.configReplace !== undefined || options.resume !== undefined) { + program.error('error: --prompt takes no --config, --config-replace, or --resume') } resolved = { mode: 'headless', prompt: options.prompt } return @@ -108,32 +153,89 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc // An empty --resume= id would silently start a fresh session downstream // (agent-loop treats '' as no-resume), so a mistyped resume must fail loud. if (options.resume === '') program.error('error: --resume needs a session id') + // The two config flags are mutually exclusive: one layers over the shipped + // tree, the other discards it, so accepting both would silently drop one. + if (options.config !== undefined && options.configReplace !== undefined) { + program.error('error: --config and --config-replace are mutually exclusive') + } resolved = { mode: 'tui', ...options.config !== undefined && { config: options.config }, + ...options.configReplace !== undefined && { configReplace: options.configReplace }, ...options.resume !== undefined && { resume: options.resume }, } }) - const web = program.command('web').description('serve the browser UI (host/port default to the shipped config)') + // Commander parses the parent (default-surface) options on either side of a + // subcommand into `program.opts()`. For a subcommand that shares none of them, + // a leaked `--config`/`-p`/`--resume` is a mistyped invocation that must fail + // loud rather than silently run and drop the input. + const rejectParentOptions = (command: string): void => { + const parent = program.opts<{ config?: string; configReplace?: string; prompt?: string; resume?: string }>() + if (parent.config !== undefined || parent.configReplace !== undefined + || parent.prompt !== undefined || parent.resume !== undefined) { + program.error(`error: ${command} takes none of --config, -p/--prompt, or --resume`) + } + } + + // Registration order is the rendered help order, so daily use comes first + // and the harness-development surfaces (`web --dev`, `meta`) come last. + // `migrate` and `upgrade` are guided fresh-session entries: they take no + // options and always mint a fresh session, so nothing is left to carry. Each + // description names the outcome, not the skill the first turn invokes. + const guided = { + migrate: 'import settings from another coding agent (Claude Code, Codex, opencode)', + upgrade: 'update this dsh installation to the latest version', + } as const + for (const mode of ['migrate', 'upgrade'] as const) { + program + .command(mode) + .description(guided[mode]) + .action(() => { + rejectParentOptions(mode) + resolved = { mode } + }) + } + + // Host and port name no default: the CLI passes neither through when the flag + // is absent, so the shipped `cordis.yml` value stands and restating it here + // would duplicate a fact this file does not own. + const web = program.command('web').description('serve the browser UI on the configured host and port') web - .option('--host ', 'override the config bind host (127.0.0.1 or 0.0.0.0)') - .option('--port ', 'override the config listen port (0 requests an OS-assigned port)') - .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds') - .option('--workspace-root ', 'parent directory for name-created workspaces') + .option('--config ', 'apply this overlay of loader patches over the shipped configuration') + .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') + .option('--port ', 'listen port; pass 0 to let the OS pick a free one') + .option('--dev', 'developer mode: hot-reload the browser client') + .option('--workspace-root ', 'parent directory for workspaces created from the browser UI') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .action((options: WebOptions) => { - // Commander parses the parent (default-surface) options on either side of - // the subcommand into `program.opts()`. `web` shares none of them, so a - // leaked `--config`/`-p`/`--resume` is a mistyped invocation that must - // fail loud rather than silently start the web server and drop it. - const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>() - if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) { - program.error('error: web takes none of --config, -p/--prompt, or --resume') - } + rejectParentOptions('web') resolved = resolveWeb(options) }) + // `--resume` is NOT redeclared here: an option a subcommand shares with its + // parent parses into `program.opts()` and leaves the subcommand's own options + // empty, so redeclaring it would silently drop the id. Commander therefore + // omits it from this subcommand's option list, hence the trailing help text. + program + .command('meta') + .description('work on the dsh source that runs this command, from any directory') + .addHelpText('after', '\nAccepts --resume to resume a persisted session from this checkout.\n') + .action(() => { + // Commander parses the parent (default-surface) options on either side of + // the subcommand into `program.opts()`. `meta` accepts only `--resume`, so + // a leaked `--config`/`-p` is a mistyped invocation that must fail loud + // rather than silently be dropped. + const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>() + if (parent.config !== undefined || parent.prompt !== undefined) { + program.error('error: meta takes neither --config nor -p/--prompt') + } + // Same reason as the default surface: an empty id would start a fresh + // session downstream instead of failing the mistyped resume. + if (parent.resume === '') program.error('error: --resume needs a session id') + resolved = { mode: 'meta', ...parent.resume !== undefined && { resume: parent.resume } } + }) + try { program.parse(argv, { from: 'user' }) } catch (error) { diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 88dbece55a..c5003cf795 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -30,7 +30,7 @@ const invocation = parseDshArgs(process.argv.slice(2), readVersion()) switch (invocation.mode) { case 'web': { const { runWeb } = await import('./web.ts') - await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts) + await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts, invocation.config) break } case 'headless': { @@ -40,7 +40,18 @@ switch (invocation.mode) { } case 'tui': { const { runTui } = await import('./tui.ts') - await runTui(invocation.config, invocation.resume) + await runTui(invocation.config, invocation.resume, undefined, undefined, invocation.configReplace) + break + } + case 'meta': { + const { runMeta } = await import('./tui.ts') + await runMeta(invocation.resume) + break + } + case 'migrate': + case 'upgrade': { + const { runSkillSession } = await import('./tui.ts') + await runSkillSession(`dsh-${invocation.mode}`) break } default: diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index d7588ccae4..8007b30d44 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -75,7 +75,8 @@ async function consumeUntilTurnEnd(frames: AsyncIterable>, export async function runHeadless(task: string): Promise { // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). const entry = new AppCLIEntry({ - configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), + configPath: fileURLToPath(new URL('../base.cordis.yml', import.meta.url)), + overlayPath: fileURLToPath(new URL('../web.cordis.yml', import.meta.url)), dev: false, port: 0, }) diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 69e79ab5d9..85f79d5fc4 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -7,9 +7,12 @@ */ import { fileURLToPath } from 'node:url' +import { resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import { AppCLIEntry } from './app-cli-entry.ts' -const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) +// The shared core every `dsh` surface mounts, plus this surface's overlay over it. +const BASE_CONFIG = fileURLToPath(new URL('../base.cordis.yml', import.meta.url)) +const WEB_OVERLAY = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) // Display-only mirror of the webserver schema's loopback host: the address the // local URL always prints. Not a source of truth — the schema is. @@ -23,6 +26,8 @@ const LOOPBACK_HOST = '127.0.0.1' * @param dev - mount the client HMR driver and watch plugin bundles for rebuilds. * @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback. * @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone. + * @param config - an overlay of loader patches applied over the shipped web + * composition, or `undefined` for none; already parsed from `--config`. */ export async function runWeb( host: string | undefined, @@ -30,9 +35,12 @@ export async function runWeb( dev: boolean, workspaceRoot: string | undefined, trustedHosts: string[] | undefined, + config?: string, ): Promise { const entry = new AppCLIEntry({ - configPath: CONFIG_PATH, + configPath: BASE_CONFIG, + overlayPath: WEB_OVERLAY, + ...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) }, dev, ...host !== undefined && { host }, ...port !== undefined && { port }, diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 45830eee30..831a969d1f 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -24,17 +24,28 @@ function exitCode(argv: string[]): number { afterEach(() => { vi.restoreAllMocks() }) describe('parseDshArgs', () => { - it('routes each mode by its shape: default TUI, -p headless, web subcommand', () => { + it('routes each mode by its shape: default TUI, -p headless, meta and web subcommands', () => { expect(parse([])).toEqual({ mode: 'tui' }) expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' }) expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) + // `meta` accepts `--resume` but does not redeclare it: a shared option parses + // into program.opts() on either side of the subcommand, and redeclaring it + // would leave the subcommand's own options empty and drop the id. + expect(parse(['meta'])).toEqual({ mode: 'meta' }) + expect(parse(['meta', '--resume', 'sess'])).toEqual({ mode: 'meta', resume: 'sess' }) + expect(parse(['--resume', 'sess', 'meta'])).toEqual({ mode: 'meta', resume: 'sess' }) + // Credential setup is option-free: it writes the Harness-home .env, so + // there is nothing for a flag to select. // Bare `web` carries no host/port: the shipped cordis.yml owns the default. expect(parse(['web'])).toEqual({ mode: 'web', dev: false }) // Host/port are unvalidated pass-throughs (the webserver schema gates them // at boot); the adapter only coerces the port string to a number. expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' }) + // Guided fresh-session entries carry nothing: bare mode discriminant only. + expect(parse(['migrate'])).toEqual({ mode: 'migrate' }) + expect(parse(['upgrade'])).toEqual({ mode: 'upgrade' }) // --trusted-host is variadic and repeatable; authorities pass through unvalidated. expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) .toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) @@ -55,6 +66,21 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '-p', 'task'])).toBe(1) expect(exitCode(['web', '--resume', 's'])).toBe(1) expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1) + // Same rule for credential setup: it shares no option with the default + // surface, so a leaked flag is a typo, not something to ignore. + // `meta` fixes its own config tree and is interactive, so --config/-p are + // rejected; an empty id is swallowed downstream exactly as above. + expect(exitCode(['meta', '--resume='])).toBe(1) + expect(exitCode(['meta', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['meta', '-p', 'task'])).toBe(1) + // `migrate`/`upgrade` take no options: any leaked default-surface flag is a + // mistyped invocation, not a silently-dropped input. + expect(exitCode(['migrate', '--resume', 's'])).toBe(1) + expect(exitCode(['migrate', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['migrate', '-p', 'task'])).toBe(1) + expect(exitCode(['upgrade', '--resume', 's'])).toBe(1) + expect(exitCode(['upgrade', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['-p', 'task', 'upgrade'])).toBe(1) }) it('exits 0 for --help (disclosing web) and --version', () => { From 9e2c3d309309d44f2523b7a5db34a57f025a4cef Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 15:43:54 +0800 Subject: [PATCH 10/77] refactor(cli): exclude tmux context and source guard --- ...2026-07-27-tmux-location-context.i18n.yaml | 6 - .../2026-07-27-tmux-location-context.md | 61 -- .../2026-07-27-tmux-location-context.zh.md | 61 -- ...8-source-guard-staging-edit-gate.i18n.yaml | 6 - ...26-07-28-source-guard-staging-edit-gate.md | 76 --- ...07-28-source-guard-staging-edit-gate.zh.md | 76 --- apps/cli/package.json | 2 - apps/cli/tui.cordis.yml | 3 - docs/config-catalog.md | 127 ++-- docs/event-producer-consumer.md | 14 +- docs/module-graph.md | 53 +- .../fixtures/guard/source-guard/cordis.yml | 38 -- .../fixtures/guard/source-guard/mock-llm.ts | 43 -- .../guard/source-guard/mount-guard.ts | 14 - .../tests/fixtures/tmux-context-driver.ts | 16 - .../tests/fixtures/tmux-context-mock-bash.ts | 46 -- .../tests/fixtures/tmux-context-mock-llm.ts | 22 - .../tests/fixtures/tmux-context.cordis.yml | 21 - examples/package.json | 2 - knip.json | 25 - packages/context/README.i18n.yaml | 4 +- packages/context/README.md | 3 +- packages/context/README.zh.md | 8 +- .../context/tmux-context/README.i18n.yaml | 6 - packages/context/tmux-context/README.md | 68 -- packages/context/tmux-context/README.zh.md | 68 -- packages/context/tmux-context/package.json | 49 -- packages/context/tmux-context/src/index.ts | 227 ------- .../context/tmux-context/src/invariant.ts | 30 - .../tmux-context/tests/tmux-context.e2e.ts | 77 --- .../tmux-context/tests/tmux-context.spec.ts | 367 ----------- packages/context/tmux-context/tsconfig.json | 40 -- packages/guard/README.i18n.yaml | 4 +- packages/guard/README.md | 1 - packages/guard/README.zh.md | 6 +- packages/guard/source-guard/README.i18n.yaml | 6 - packages/guard/source-guard/README.md | 88 --- packages/guard/source-guard/README.zh.md | 88 --- packages/guard/source-guard/package.json | 56 -- packages/guard/source-guard/src/index.ts | 319 ---------- packages/guard/source-guard/src/invariant.ts | 85 --- .../source-guard/tests/invariant.spec.ts | 134 ---- .../tests/loader-composition.e2e.ts | 93 --- .../source-guard/tests/source-guard.spec.ts | 581 ------------------ packages/guard/source-guard/tsconfig.json | 42 -- pnpm-lock.yaml | 86 --- tsconfig.host.json | 2 - 47 files changed, 97 insertions(+), 3153 deletions(-) delete mode 100644 .agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-27-tmux-location-context.md delete mode 100644 .agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md delete mode 100644 .agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md delete mode 100644 .agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.zh.md delete mode 100644 examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml delete mode 100644 examples/headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts delete mode 100644 examples/headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts delete mode 100644 examples/headless-agent/tests/fixtures/tmux-context-driver.ts delete mode 100644 examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts delete mode 100644 examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts delete mode 100644 examples/headless-agent/tests/fixtures/tmux-context.cordis.yml delete mode 100644 packages/context/tmux-context/README.i18n.yaml delete mode 100644 packages/context/tmux-context/README.md delete mode 100644 packages/context/tmux-context/README.zh.md delete mode 100644 packages/context/tmux-context/package.json delete mode 100644 packages/context/tmux-context/src/index.ts delete mode 100644 packages/context/tmux-context/src/invariant.ts delete mode 100644 packages/context/tmux-context/tests/tmux-context.e2e.ts delete mode 100644 packages/context/tmux-context/tests/tmux-context.spec.ts delete mode 100644 packages/context/tmux-context/tsconfig.json delete mode 100644 packages/guard/source-guard/README.i18n.yaml delete mode 100644 packages/guard/source-guard/README.md delete mode 100644 packages/guard/source-guard/README.zh.md delete mode 100644 packages/guard/source-guard/package.json delete mode 100644 packages/guard/source-guard/src/index.ts delete mode 100644 packages/guard/source-guard/src/invariant.ts delete mode 100644 packages/guard/source-guard/tests/invariant.spec.ts delete mode 100644 packages/guard/source-guard/tests/loader-composition.e2e.ts delete mode 100644 packages/guard/source-guard/tests/source-guard.spec.ts delete mode 100644 packages/guard/source-guard/tsconfig.json diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml deleted file mode 100644 index cbac9d1251..0000000000 --- a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml +++ /dev/null @@ -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 .agents/notes/implemented/feature/2026-07-27-tmux-location-context.md -2026-07-27-tmux-location-context.md: b6f0b0cc85fa4808bd761f30bdbab8ad65e61717 -2026-07-27-tmux-location-context.zh.md: e79214e03296a87c622df91437385950c00eded6 diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md deleted file mode 100644 index b6f0b0cc85..0000000000 --- a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md +++ /dev/null @@ -1,61 +0,0 @@ -# Agent Note: tmux-location context - -Status: implemented - -English | [中文](2026-07-27-tmux-location-context.zh.md) - -## Problem - -An agent running inside tmux has no way to tell the model where it is: which session, window, and pane the process occupies, and how the window is laid out. A user directing several panes wants the model to orient itself to its own location so instructions like "the pane below" or "this window" resolve. The location must reach the model as durable, reconstructable context, not a system-prompt value rewritten in place, and must cost nothing when the location has not changed. - -tmux exposes this without a daemon: `$TMUX_PANE` names the process's pane, and `tmux display-message -t "$TMUX_PANE" -p ''` prints any pane/window/session field. The open question was how to observe it — pull on each preparation, or push from a tmux hook — and how to avoid a per-step token cost and hidden process-local state. - -## Decision - -`@deepseek-ai/dsh-tmux-context` is an opt-in function plugin in `packages/context/tmux-context/`, alongside the other bounded request-context enrichments that define neither a tool nor a service. Shipped examples do not mount it because tmux-location disclosure and its token cost are deployment policy. - -**Pull on the first step of each turn, not a tmux push.** The plugin prepends an `agent/step` listener and acts only when `step === 1`. A pull model needs no background process, no hook installation in the user's tmux, and no teardown; it re-reads current state each turn so a moved, renamed, or re-laid-out pane is picked up naturally. Gating on the first step makes the reading per-turn: a location is stable within a turn, and re-querying every step would add cost without new information. A pane moved mid-turn is reflected on the next turn, which is the accepted tradeoff for the simpler design. - -**Read through the `ctx.bash` seam, never raw `child_process`.** The listener runs the tmux/`ps` read commands through `ctx.bash`, so the deployment's sandbox and policy apply and the plugin owns no subprocess code. Absent `ctx.bash`, absent tmux env, a wrong field count, or an empty pane id each make the attempt a no-op, matching how `workspace-context` no-ops without an `fs` provider. - -**Detect a real pane by tty, not by `$TMUX_PANE` alone.** `$TMUX_PANE` is inherited: a terminal launched from a tmux shell (a VS Code integrated terminal, a desktop launcher) carries `$TMUX`/`$TMUX_PANE` from that ancestor even though the process does not live in that pane, which otherwise injects a stale, wrong location. The command resolves this process's controlling terminal with `ps -o tty= -p ` (the agent's own pid, passed in-process) and compares it to the pane's `#{pane_tty}`; fields are emitted only on a match. A genuine pane owns this process's tty; an inherited environment names some other pane's tty and reads as "not in tmux". Checking `$TMUX` instead does not help — it is inherited identically. This is the definitive discriminator and needs no allowlist of terminal emulators. - -**Own location and layout only.** The queried fields are session name, window index/name, pane index/id, window/pane active flags, and `window_layout`. Pane and window pixel sizes are excluded (layout tree conveys structure; sizes are noisy and change on every terminal resize). Sibling-pane contents are never captured (`capture-pane`), keeping the reading small and avoiding scraping unrelated, possibly sensitive, output. - -**Inject only on change, with optional interval floor.** When due, the plugin calls `agent.inject()` for one `user/message` with source `{ kind: 'plugin', plugin: 'tmux-context' }`. Change suppression compares the rendered state block (everything after the turn preamble line) against the latest injection of this source, found by scanning raw durable session events — so the schedule survives compaction and process resume without a process-local cache. The optional `refreshIntervalMs` (manually validated as a non-negative safe integer at plugin load) additionally suppresses injections within that window of the latest one. - -### Text - -```text -tmux location (turn ): -session , window "", pane -window active=<0|1>, pane active=<0|1>, layout -``` - -The turn preamble is the volatile first line; the two-line state block below it is the unit compared for change suppression, so re-injection is driven by tmux state, not loop position. - -### Durability and request reconstruction - -Each reading is a normal surface node until compaction shadows it; the plugin contributes nothing to system-prompt assembly and `request/header` carries no tmux-context text. The reading records a preparation attempt, not a committed step: because the prepended listener runs first, its append may remain when a later `agent/step` listener cancels or fails the attempt, and the append-only log performs no rollback. - -The published `./invariant` companion registers no runtime check: a reading is a per-turn snapshot of external tmux state, so the session holds no cross-event relation to validate, and scheduling and format stay pinned by the package's pipeline tests. - -## Consequences - -An agent booted inside tmux now receives its own session/window/pane location and window layout as durable, source-attributed context, updated per turn when the location changes. Deployments opt in through cordis.yml; the default spine and shipped examples stay silent. Outside a real tmux pane — including a terminal that merely inherited `$TMUX`/`$TMUX_PANE` — or without a `ctx.bash` executor, the plugin is inert with no error, so composing it is safe everywhere. Because the reading is one durable `user/message`, it survives compaction as ordinary history, contributes nothing to system-prompt assembly or request headers, and costs at most one two-line message per changed turn. The pull model adds one bash execution (through the sandboxed bash seam) on the first step of each turn that is due — internally a `ps` tty probe, a `tmux display-message` tty query, and the field query. Only the optional interval floor suppresses the query itself; an unchanged location is known only after querying, so it suppresses the injection alone. - -## Testing - -Unit tests pin: first-step injection and source/surface metadata; the `$TMUX_PANE`-keyed command including its `#{pane_tty}`-vs-`ps -o tty=` guard; step-gating; change suppression across turns and re-injection on a moved pane; positive-interval suppression and threshold; every no-op path (no bash, nonzero exit, wrong field count, empty pane id, aborted signal); prepended ordering before ordinary `agent/step` listeners; resilience to a corrupt prior reading (non-text block, single-line text); and config rejection of negative and non-integer intervals. Per-file coverage is 100%. - -## Alternatives considered - -- **Push from a tmux hook / background watcher** — rejected: requires installing hooks in the user's tmux and a background process with teardown, to gain mid-step freshness that per-turn context does not need. -- **Run every step** — rejected: location is stable within a turn; re-querying adds token cost without new information. Gating on `step === 1` yields per-turn readings. -- **Raw `child_process`** — rejected: bypasses the sandbox/policy seam and hand-rolls subprocess code the `ctx.bash` executor already owns. -- **Include pane/window pixel sizes** — rejected: sizes churn on every resize and add noise; the layout tree already conveys structure. -- **Scrape sibling panes with `capture-pane`** — rejected: large, noisy, and privacy-sensitive; out of scope for "own location". -- **Dynamic system-prompt section** — rejected: replacing a value erases the earlier readings behind prior reasoning and is not reconstructable; one durable attributed message records each location where it became visible. -- **Trust `$TMUX_PANE` (or `$TMUX`) presence** — rejected: both are inherited by terminals launched from a tmux shell (VS Code integrated terminal), so a non-pane process injects a stale location. The pane `#{pane_tty}` vs. this process's controlling tty is the definitive check. -- **Denylist known terminal emulators (e.g. `TERM_PROGRAM=vscode`)** — rejected: a partial, ever-growing list that still misses other launchers; the tty match is exact and launcher-agnostic. -- **A runtime invariant validating each reading's turn, position, and format** — shipped initially, then removed: it re-derived the producer's own scheduling from the log and asserted a regex over text the same package had just rendered, so it restated `apply()` rather than checking an independent relation. Every failure it could report required an edit to this package, which its pipeline tests already catch. Reintroduce a companion check only for a relation the plugin does not itself compute — for example if readings gain cross-turn ordering or enclosure obligations that another package can violate. diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md deleted file mode 100644 index e79214e032..0000000000 --- a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md +++ /dev/null @@ -1,61 +0,0 @@ -# Agent Note:tmux 位置上下文 - -Status: implemented - -[English](2026-07-27-tmux-location-context.md) | 中文 - -## 问题 - -运行在 tmux 内的 agent 无法告诉模型自己身在何处:进程占据哪个 session、window、pane,以及 window 如何布局。当用户操作多个 pane 时,希望模型能对自身位置有所定位,从而让"下方的 pane""这个 window"之类的指令得以解析。位置必须以持久、可重建的上下文形式送达模型,而非在原地被改写的系统提示值,并且当位置未变化时不产生任何成本。 - -tmux 无需守护进程即可暴露这些信息:`$TMUX_PANE` 标识进程所在 pane,`tmux display-message -t "$TMUX_PANE" -p ''` 可打印任意 pane/window/session 字段。待决问题在于如何观测——在每次准备时拉取,还是由 tmux hook 推送——以及如何避免逐步骤 token 成本与隐藏的进程内状态。 - -## 决策 - -`@deepseek-ai/dsh-tmux-context` 是位于 `packages/context/tmux-context/` 的可选启用型函数插件,与其他既不定义工具也不定义服务的有界请求上下文增强并列。随附示例不挂载它,因为 tmux 位置披露及其 token 成本属于部署策略。 - -**在每轮的第一个 step 拉取,而非 tmux 推送。** 插件前置注册一个 `agent/step` 监听器,仅在 `step === 1` 时动作。拉取模型无需后台进程、无需在用户的 tmux 中安装 hook、也无需清理;它每轮重新读取当前状态,因此被移动、改名或重新布局的 pane 都会被自然感知。以第一个 step 为门槛使读数按轮次生成:位置在一轮内是稳定的,逐步骤重复查询只会增加成本而不带来新信息。轮次中途移动的 pane 会在下一轮反映,这是换取更简单设计所接受的取舍。 - -**通过 `ctx.bash` seam 读取,绝不用裸 `child_process`。** 监听器通过 `ctx.bash` 运行 tmux/`ps` 只读命令,从而应用部署方的沙箱与策略,插件不拥有任何子进程代码。`ctx.bash` 缺失、tmux 环境缺失、字段数不符或 pane id 为空,都会使本次尝试成为空操作,与 `workspace-context` 在无 `fs` provider 时的空操作一致。 - -**以 tty 判定真实 pane,而非仅凭 `$TMUX_PANE`。** `$TMUX_PANE` 会被继承:从 tmux shell 启动的终端(VS Code 集成终端、桌面启动器)会从该祖先进程带上 `$TMUX`/`$TMUX_PANE`,即使进程并不位于那个 pane 中,否则就会注入一个陈旧且错误的位置。命令用 `ps -o tty= -p `(在进程内传入 agent 自身的 pid)解析本进程的控制终端,并与 pane 的 `#{pane_tty}` 比较;只有匹配时才输出字段。真正的 pane 拥有本进程的 tty;继承而来的环境指向的是另一个 pane 的 tty,因而被读作"不在 tmux 中"。改为检查 `$TMUX` 也无济于事——它同样会被继承。这是决定性的判别依据,且无需维护终端模拟器名单。 - -**仅自身位置与布局。** 查询字段为 session name、window index/name、pane index/id、window/pane 活动标志以及 `window_layout`。省略 pane 与 window 像素尺寸(布局树已传达结构;尺寸嘈杂且每次终端缩放都会变化)。从不采集相邻 pane 内容(`capture-pane`),使读数保持小巧,并避免抓取无关、可能敏感的输出。 - -**仅在变化时注入,并可选间隔下限。** 需要时,插件调用 `agent.inject()` 注入一条来源为 `{ kind: 'plugin', plugin: 'tmux-context' }` 的 `user/message`。变化抑制将渲染出的状态块(轮次前缀行之后的全部内容)与该来源的最近一次注入比较,后者通过扫描原始持久会话事件获得——因此调度可跨压缩与进程恢复存续,无需进程内缓存。可选的 `refreshIntervalMs`(在插件加载时手动校验为非负安全整数)会额外抑制距最近一次注入不足该窗口的注入。 - -### 文本 - -```text -tmux location (turn ): -session , window "", pane -window active=<0|1>, pane active=<0|1>, layout -``` - -轮次前缀是易变的首行;其下的两行状态块才是变化抑制所比较的单元,因此重新注入由 tmux 状态驱动,而非循环位置。 - -### 持久性与请求重建 - -每条读数在被压缩遮蔽前都是普通表层节点;插件对系统提示装配毫无贡献,`request/header` 也不携带任何 tmux-context 文本。读数记录的是一次准备尝试,而非已提交的 step:由于前置监听器最先运行,当后续 `agent/step` 监听器取消或失败时其追加可能仍会保留,只追加的日志不做回滚。 - -发布的 `./invariant` 伴生插件不注册任何运行时检查:读数是外部 tmux 状态的按轮快照,会话中不存在需要校验的跨事件关系,调度与格式由本包的管线测试固定。 - -## 后果 - -启动于 tmux 内的 agent 现在会以持久、带来源标记的上下文收到自身的 session/window/pane 位置及 window 布局,并在位置变化时按轮次更新。部署方通过 cordis.yml 选择启用;默认 spine 与随附示例保持沉默。在真实 tmux pane 之外——包括仅继承了 `$TMUX`/`$TMUX_PANE` 的终端——或没有 `ctx.bash` 执行器时,插件保持惰性且不报错,因此在任何地方组合它都安全。由于读数是一条持久的 `user/message`,它作为普通历史经受压缩,对系统提示装配与请求头毫无贡献,且每个发生变化的轮次至多花费一条两行消息。拉取模型在每个到期轮次的第一个 step 增加一次 bash 执行(经沙箱化的 bash seam)——内部包含一次 `ps` tty 探测、一次 `tmux display-message` tty 查询和字段查询。只有可选的间隔下限会抑制查询本身;位置是否变化只有在查询之后才知道,因此它只抑制注入。 - -## 测试 - -单元测试固定了:首个 step 的注入及来源/表层元数据;以 `$TMUX_PANE` 为键的命令(含其 `#{pane_tty}` 与 `ps -o tty=` 的比对守卫);step 门槛;跨轮次的变化抑制与 pane 移动时的重新注入;正间隔抑制与阈值;每条空操作路径(无 bash、非零退出、字段数不符、pane id 为空、信号已取消);前置排序先于普通 `agent/step` 监听器;对损坏的历史读数(非文本块、单行文本)的容错;以及配置对负值与非整数间隔的拒绝。逐文件覆盖率为 100%。 - -## 考虑过的替代方案 - -- **由 tmux hook / 后台监视器推送**——否决:需要在用户的 tmux 中安装 hook,并引入带清理的后台进程,只为换取按轮次上下文并不需要的步内新鲜度。 -- **每个 step 都运行**——否决:位置在一轮内稳定;重复查询只增加 token 成本而无新信息。以 `step === 1` 为门槛得到按轮次读数。 -- **裸 `child_process`**——否决:绕过沙箱/策略 seam,并手写 `ctx.bash` 执行器已拥有的子进程代码。 -- **包含 pane/window 像素尺寸**——否决:尺寸每次缩放都变动、徒增噪声;布局树已传达结构。 -- **用 `capture-pane` 抓取相邻 pane**——否决:庞大、嘈杂且涉及隐私;超出"自身位置"范围。 -- **动态系统提示区块**——否决:替换某个值会抹去支撑先前推理的历史读数且不可重建;单条持久且带来源的消息在每个位置变得可见时予以记录。 -- **信任 `$TMUX_PANE`(或 `$TMUX`)存在即可**——否决:两者都会被从 tmux shell 启动的终端(VS Code 集成终端)继承,于是非 pane 进程会注入陈旧位置。pane 的 `#{pane_tty}` 与本进程控制终端的比对才是决定性检查。 -- **对已知终端模拟器设黑名单(如 `TERM_PROGRAM=vscode`)**——否决:名单不完整且会不断增长,仍会漏掉其他启动器;tty 比对精确且与启动器无关。 -- **用运行时 invariant 校验每条读数的轮次、位置与格式**——最初随包发布,随后移除:它从日志中重新推导生产者自身的调度,并对同一个包刚刚渲染出的文本断言正则,因此只是重述 `apply()`,而非检查一条独立关系。它能报出的每种失败都必须先修改本包,而这些本包的管线测试已经覆盖。仅当出现插件自身并不计算的关系时才重新引入伴生检查——例如读数将来具备可被其他包破坏的跨轮次顺序或包裹义务。 diff --git a/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.i18n.yaml deleted file mode 100644 index 91227d80e1..0000000000 --- a/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.i18n.yaml +++ /dev/null @@ -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 .agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md -2026-07-28-source-guard-staging-edit-gate.md: 8452006190166c783efafc398566ef7f4da10323 -2026-07-28-source-guard-staging-edit-gate.zh.md: 83589ce833e4aa74968b40247848685a1e030f6b diff --git a/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md deleted file mode 100644 index 8452006190..0000000000 --- a/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md +++ /dev/null @@ -1,76 +0,0 @@ -# Agent Note: source-guard denies direct staging-checkout edits - -Status: implemented - -English | [中文](2026-07-28-source-guard-staging-edit-gate.zh.md) - -## Problem - -The [`dsh-customize`](../../../../skills/dsh-customize/SKILL.md) skill governs every personal change to a dsh source checkout: implement in a task worktree branched from the staging tip, then integrate under `.agents/merge.lock`. Its central rule is negative — do not edit the personal staging checkout directly — and a negative rule delivered only as prompt text fails in exactly the case that matters. An agent that never loads the skill never sees the rule, and one that loads it early can still forget it thirty tool calls later. The failure is silent and expensive: commits land on the staging branch that the launcher runs from, outside any task branch, with no lock held and no rollback worktree. - -Prompt guidance cannot fix this, because the guidance is what went unread. The rule needs an enforcement point. - -## Decision - -`@deepseek-ai/dsh-source-guard` (`packages/guard/source-guard/`) is a `tools/pre-execute` listener that returns `{kind: 'deny', reason}` for a `write` or `edit` whose target resolves inside a protected staging worktree, unless the calling session's durable log already records a successful `skill` call naming `dsh-customize`. It registers no service and contributes no prompt text or tool schema; an allowed call is indistinguishable from one made without the plugin. It is not in any shipped default composition. - -### Git identity from files, not a path prefix and not `git` - -Whether a path is protected is decided by reading `.git`, its `gitdir:` pointer, and `HEAD`. Three shapes resolve: a plain clone (`.git` is a directory that is its own common dir), a linked worktree (`.git` is a file pointing at `/worktrees/`, whose common dir is two levels up), and a detached HEAD (`HEAD` holds a raw object id and names no branch). A `gitdir:` pointer resolves whether absolute — what `git worktree add` writes — or relative, which git resolves against the worktree directory holding it. - -Denial requires the target's worktree to match the launcher's on both identities: the same shared git directory and the same branch. Both come from resolving `protectedCheckout`, so nothing about the protected branch is configured. An earlier revision matched a `dsh-staging/*` name pattern instead; the exact-branch rule replaced it because a pattern is wrong in both directions. It denied every sibling staging worktree an old install had left behind, none of which runs a launcher, and it silently protected nothing for a maintainer whose staging branch follows no naming convention — a fatal property for a shipped default that must hold for checkouts [`scripts/install.sh`](../../../../scripts/install.sh) did not create. - -A path-prefix rule would have been wrong, not merely imprecise. The task worktrees the skill prescribes live *inside* the protected tree at `/.worktrees/...`, so a prefix rule would deny every edit the workflow requires. Resolution walks outward from the target and stops at the first enclosing worktree, so it reports the innermost one: a nested task worktree answers with its own task branch and is allowed, while the launcher's own tree answers with the launcher's branch and is denied. - -Two path details decide whether the gate holds at all, and both are enforcement, not polish. Repository identity is compared on symlink-resolved paths (`canonicalPath` from `dsh-sandbox`), because a session cwd under `/var/...` and a configured path under `/private/var/...` are the same macOS directory and a lexical comparison would fail open on every write. And a relative `file_path` is resolved against the calling session's workspace, exactly as `dsh-tool-fs` resolves it; judging only absolute paths would have left a relative path as an unguarded route to a protected file. - -`protectedCheckout` names a path inside the guarded checkout, defaulting to this module's own file. That resolves the checkout the running harness was launched from — the live deployment, whatever its branch is named. A harness running from an installed copy resolves a different repository, or none, and guards nothing; the rule is meaningless outside a source checkout. - -The shipped TUI composition loads the plugin with these defaults, so every source install is protected without configuration. It is inert for an ordinary project: a workspace in another repository, or none, never matches the launcher's identities. - -### Satisfaction replayed from the durable log - -The gate lifts on a `tool/call` naming the `skill` tool whose arguments parse to `{name: }`, paired by call id with a non-error `tool/result`. Both fields are already durable (`packages/core/session/src/types.ts`), so this needs no new session event and no coupling to skill-provider internals. - -The log is the only state. In-memory satisfaction (the `WeakMap` shape [`repeat-tool-guard`](../../archived/feature/2026-07-08-repeat-tool-guard.md) uses for its chains) would be smaller, but it loses satisfaction on resume: a resumed session that already read the skill would be told to read it again, and the denial would look like a bug rather than a rule. Replay costs a scan bounded by the first hit and buys resume correctness. - -### Fail open, deliberately - -A path outside any worktree, a detached HEAD, a foreign repository, a malformed `gitdir:` pointer, and unreadable metadata all leave the call to the rest of the chain. The alternative — denying whenever git identity is unavailable — converts any `.git` permission problem into a harness that cannot write files at all. The guard exists to prevent one specific, recoverable mistake; it must not become a larger outage than the mistake. - -### Narrow scope - -`read` is never gated: inspecting staging violates nothing, and the skill explicitly permits read-only questions. `bash` is not gated either. Reliably classifying mutating shell commands is a matcher problem with no honest completion condition, so a determined model can still change staging through a shell. This is a boundary against forgetting, not a sandbox against intent. - -## Alternatives considered - -- **Advisory reminder instead of denial** (`additionalContexts` on `tools/post-execute`, the `repeat-tool-guard` shape). Rejected: the write has already happened when the reminder arrives, so the violation is committed and the guidance is again just text. -- **`{kind: 'ask'}` routed to approval.** Rejected: it prompts on every legitimate task-worktree edit in the common case, and degrades to denial in a composition without approval support, making behavior depend on unrelated plugins. -- **Running `git rev-parse` through `ctx.subprocess`.** Rejected after measuring the alternative: two file reads answer the same question with no process spawn per gated write, no `git` on `PATH` requirement, and no subprocess dependency. Reading `.git` and `HEAD` is a stable on-disk format, not an implementation detail. -- **Explicit `protectedRoots` config with no detection.** Rejected: it makes the common case require configuration to be correct, and a stale absolute path silently disables protection. -- **A configurable staging-branch name pattern** (`stagingBranchPatterns`, default `dsh-staging/*`). Shipped first, then removed: it protects the wrong set in both directions — every stale sibling worktree that runs no launcher, and nothing at all for a maintainer whose branch is named otherwise. Deriving the branch from the launcher needs no configuration and cannot be misconfigured. -- **Auto-detecting the checkout with no override.** Rejected: the detection is a default, not a law; a deployment guarding a different checkout, or running from an installed copy, needs the explicit value. -- **Denying everything under the checkout root, `.worktrees/` included.** Rejected: it blocks the workflow the skill prescribes, so the guard would fire on every legitimate task edit. -- **Gating `bash` with a mutating-command matcher.** Deferred, not rejected: worth revisiting if bypasses are observed in practice. A matcher that is wrong in either direction is worse than an honestly narrow gate. - -## Consequences - -The rule now holds without depending on the model having read it, and the denial names the path, the branch, and the skill, so the model's next action is determined rather than guessed. Enforcement sits at the operation boundary that owns the decision, so it cannot be bypassed by prompt filtering or listener order. - -Shipping it in the TUI default means every source install is protected without configuration, and the protection follows the launcher across upgrades because the branch is derived rather than named. The cost of that reach is that the plugin loads for every user, including those whose workspace it can never match. - -What it cost otherwise: the guard is only as complete as its tool list, and `bash` remains open. Worktree identity is cached per directory for the plugin's lifetime, so a mid-session branch switch is not observed on either side. Only the launcher's own checkout is protected, so a stale sibling stays editable. Loading the skill lifts the gate for the whole session without verifying the workflow was actually followed — the gate proves the instructions were read, not obeyed. Satisfaction is per session, so a subagent with its own session must load the skill itself. - -## Testing - -Unit suites drive a real agent loop against a mock adapter over real git-metadata fixtures — a staging worktree, a task worktree nested inside it, a plain clone, a foreign repository on a staging-named branch, a detached HEAD, absolute and relative `gitdir:` pointers, a symlinked route to one repository, a malformed pointer, and unreadable metadata — covering both source files to per-file 100%. A companion `invariant.ts` validates the durable denial's shape, since the refusal text is the package's only model-visible output and is actionable only when it names the path, branch, and skill. - -The real-composition smoke boots `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml` through the Loader and the headless app, and asserts three things about the assembled run: the tool result is an error, its text is the exact denial, and the targeted file still holds its original bytes — enforcement before dispatch, not advice after it. - -An ACP snapshot scenario (`source-guard-staging-deny`) originally owned the assembled transcript, seeding a staging worktree in the harness's generated cwd through a new `Scenario.prepareCwd` hook — git never tracks an entry named `.git` and `.gitignore` excludes every `worktrees/` directory, so the fixture committed the two `HEAD` bodies and the hook assembled the real layout. Authoring it paid for itself immediately: it exposed both path defects above (the transcript showed `fs-policy` answering first wherever the guard had quietly declined to judge) and then caught its own first fixture, whose ignored `worktrees/` path passed locally from an untracked file. The scenario was later removed with the assembled-run evidence consolidated into the Loader-composition smoke; the `prepareCwd` hook it introduced remains part of the snapshot harness for repository-shaped fixtures. - -## Related - -- [The personal-staging maintenance skills Agent Note](../process/2026-07-23-personal-staging-maintenance-skills.md) — the workflow this gate enforces one rule of. That note owns the skills' content and discovery; this one owns the enforcement point and holds no authority over the workflow itself. -- [The interception-seams Agent Note](2026-06-30-interception-seams.md) — the `tools/pre-execute` `allow`/`deny`/`ask` vocabulary this gate's denial uses. -- [The repeat-tool-guard Agent Note](../../archived/feature/2026-07-08-repeat-tool-guard.md) — the sibling guard whose advisory shape this one deliberately does not take. diff --git a/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.zh.md b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.zh.md deleted file mode 100644 index 83589ce833..0000000000 --- a/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.zh.md +++ /dev/null @@ -1,76 +0,0 @@ -# Agent Note: source-guard 拒绝直接编辑 staging 检出目录 - -Status: implemented - -[English](2026-07-28-source-guard-staging-edit-gate.md) | 中文 - -## Problem - -[`dsh-customize`](../../../../skills/dsh-customize/SKILL.md) skill(技能)规范对 dsh 源码检出的每项个人变更:先在从 staging 分支顶端分出的任务 worktree 中实现,再于 `.agents/merge.lock` 保护下完成集成。它的核心规则是一条禁令:不得直接编辑个人 staging 检出目录;而若只通过提示词文本传达禁令,它恰好会在最要紧的场景中失效。从未加载该 skill 的 agent(智能体)根本看不到规则;即便尽早加载,也仍可能在三十次工具调用后将其忘掉。这种失败既静默又代价高昂:提交会落在启动器实际运行的 staging 分支上,不属于任何任务分支,既未持有锁,也没有用于回滚的 worktree。 - -提示词指导无法解决这个问题,因为未被阅读的正是这些指导。该规则需要一个强制执行点。 - -## Decision - -`@deepseek-ai/dsh-source-guard`(`packages/guard/source-guard/`)是一个 `tools/pre-execute` 监听器;它会返回 `{kind: 'deny', reason}`,拒绝目标解析到受保护 staging worktree 内的 `write` 或 `edit`,除非调用会话的持久日志已经记录过一次成功的 `skill` 调用,且名称为 `dsh-customize`。它不注册服务,也不贡献提示词文本或工具 schema;获准的调用与未加载该插件时的调用没有区别。任何已交付的默认组合都不包含它。 - -### 从文件而非路径前缀或 `git` 判定 Git 身份 - -系统通过读取 `.git`、其中的 `gitdir:` 指针以及 `HEAD` 来判断路径是否受保护。它可以解析三种形态:普通克隆(`.git` 是目录,且自身就是共享目录)、链接 worktree(`.git` 是文件,指向 `/worktrees/`,其共享目录位于上两级)以及 HEAD 分离状态(`HEAD` 保存原始对象 id,不指向任何分支)。`gitdir:` 指针无论是绝对路径(`git worktree add` 写入的形式)还是相对路径都可以解析;Git 会以包含该指针的 worktree 目录为基准解析相对路径。 - -只有目标的 worktree 在两项身份上都与启动器的 worktree 匹配时才会拒绝:共用同一个共享 Git 目录,且分支相同。这两项身份均通过解析 `protectedCheckout` 得出,因此无需配置受保护分支的任何信息。较早版本则匹配 `dsh-staging/*` 名称模式;现已改用确切分支规则,因为模式会在两个方向上出错。它会拒绝旧安装留下的每一个同级 staging worktree,尽管其中没有任何一个运行着启动器;对于 staging 分支不遵循任何命名约定的维护者,它又会静默地完全不提供保护——而已交付的默认配置必须在 [`scripts/install.sh`](../../../../scripts/install.sh) 未创建的检出目录上也能生效,这一属性是致命的。 - -路径前缀规则不仅不精确,而且本身就是错误的。该 skill 规定的任务 worktree 位于受保护树*内部*的 `/.worktrees/...`,因此前缀规则会拒绝工作流要求的每一次编辑。解析过程从目标向外逐层查找,遇到第一个所属 worktree 时停止,因此返回最内层的 worktree:嵌套的任务 worktree 会返回自身的任务分支并获准,而启动器自身所在的树会返回启动器的分支并被拒绝。 - -有两个路径细节决定门禁究竟能否生效,二者都是强制执行要求,而非细节润色。仓库身份会按解析符号链接后的路径进行比较(使用 `dsh-sandbox` 的 `canonicalPath`),因为位于 `/var/...` 下的会话 cwd 和位于 `/private/var/...` 下的配置路径在 macOS 上是同一个目录,若按路径字符串比较,每次写入都会故障放行(fail-open)。此外,相对 `file_path` 会完全按照 `dsh-tool-fs` 的方式,相对于调用会话的工作区解析;若只判断绝对路径,相对路径就会成为绕过门禁访问受保护文件的路径。 - -`protectedCheckout` 指定受保护检出目录内的一条路径,默认值为本模块自身的文件。由此解析出运行中 harness 的启动来源检出目录——当前运行的部署,无论其分支采用什么名称。若 harness 从已安装副本运行,解析出的会是另一个仓库或没有仓库,因此不会保护任何内容;该规则在源码检出之外没有意义。 - -已交付的 TUI 组合会以这些默认值加载插件,因此每个源码安装无需配置即可受到保护。对于普通项目,它不会生效:若工作区位于其他仓库中,或不存在工作区,就绝不会匹配启动器的身份。 - -### 从持久日志回放满足状态 - -如果日志中存在一条 `tool/call`,它调用名为 `skill` 的工具,参数可解析为 `{name: }`,且按调用 id 能配对到非错误的 `tool/result`,门禁即解除。二者都已持久化(`packages/core/session/src/types.ts`),因此无需新增会话事件,也不与 skill 提供方内部实现耦合。 - -日志是唯一状态。在内存中记录满足状态(`WeakMap` 结构,[`repeat-tool-guard`](../../archived/feature/2026-07-08-repeat-tool-guard.md) 将其用于调用链)所需实现会更小,但恢复后满足状态会丢失:一个已经读取过该 skill 的恢复会话会被要求再次读取,而这次拒绝看起来会像缺陷而不是规则。回放的代价是扫描日志,但首次命中即停止,并换来恢复行为正确。 - -### 刻意采用故障放行 - -目标路径不在任何 worktree 内、HEAD 分离、属于其他仓库、`gitdir:` 指针格式错误或元数据不可读时,调用都会交给调用链的其余部分处理。反过来,只要无法判定 Git 身份就拒绝,会让任何 `.git` 权限问题都导致 harness 完全无法写文件。该 guard 旨在防止一种特定且可恢复的错误,不得造成比该错误更严重的故障。 - -### 范围收窄 - -`read` 从不受门禁限制:检查 staging 不会违反任何规则,而且该 skill 明确允许只读提问。`bash` 同样不受门禁限制。要可靠判定哪些 shell 命令会修改状态,需要构造一个无法给出可信完备标准的匹配器,因此执意修改的模型仍可通过 shell 修改 staging。这是一道防止遗忘的边界,不是阻止刻意操作的沙箱。 - -## Alternatives considered - -- **用建议性提醒代替拒绝**(使用 `additionalContexts`,挂载在 `tools/post-execute` 上,采用 `repeat-tool-guard` 的形态)。不予采纳:提醒到达时写入已经发生,违规已成事实,而指导又一次沦为纯文本。 -- **将 `{kind: 'ask'}` 交给审批。** 不予采纳:在常见场景中,它会对任务 worktree 内每次合法编辑都发起询问;在没有审批支持的组合中还会退化为拒绝,使行为取决于无关插件。 -- **运行 `git rev-parse`,并通过 `ctx.subprocess` 执行。** 对替代方案进行实测后不予采纳:读取两个文件即可回答同一问题,每次受门禁限制的写入都无需 spawn 进程,不要求 `git` 存在于 `PATH` 中,也不依赖子进程。读取 `.git` 与 `HEAD` 所依据的是稳定的磁盘格式,而非实现细节。 -- **显式配置 `protectedRoots`,不做检测。** 不予采纳:这会让常见场景的保护效果依赖配置正确性,而陈旧的绝对路径会静默禁用保护。 -- **可配置的 staging 分支名称模式**(`stagingBranchPatterns`,默认 `dsh-staging/*`)。最初随产品交付,随后删除:它从两个方向划错了保护范围——既纳入每个不运行启动器的陈旧同级 worktree,又完全不保护分支另有名称的维护者。由启动器派生分支无需配置,也不可能配置错误。 -- **自动检测检出目录,不提供覆盖项。** 不予采纳:检测只是默认行为,而非不可更改的规定;若部署要保护另一个检出目录,或自身从已安装副本运行,就需要显式值。 -- **拒绝检出根目录下的一切操作,包括 `.worktrees/`。** 不予采纳:这会阻断该 skill 规定的工作流,让 guard 在每次合法任务编辑时触发。 -- **用修改类命令匹配器把守 `bash`。** 推迟而非否决:如果实际观察到绕过行为,值得重新考虑。任一方向判断错误的匹配器,都不如如实限定范围的门禁。 - -## Consequences - -如今,该规则无需依赖模型已经读过它也能生效;拒绝理由会列出路径、分支与 skill,让模型的下一步操作明确,无需猜测。强制执行位于拥有该决策的操作边界,因此提示词过滤或监听器顺序都无法绕过它。 - -将其纳入 TUI 默认组合意味着每个源码安装无需配置即可受到保护;由于分支是派生而非按名称指定,保护会在升级时跟随启动器。这种覆盖范围的代价是插件会为每位用户加载,包括工作区永远不可能匹配启动器身份的用户。 - -除此之外的代价是:guard 的完整程度受限于其工具列表,`bash` 仍保持开放。worktree 身份在插件生命周期内按目录缓存,因此无法观察到任一侧在会话中途切换分支。只保护启动器自身的检出目录,因此陈旧的同级检出目录仍可编辑。加载该 skill 会为整个会话解除门禁,却不会验证工作流是否确实得到遵循——门禁只能证明指令已被阅读,不能证明已被执行。满足状态按会话隔离,因此拥有独立会话的 subagent 必须自行加载该 skill。 - -## Testing - -单元测试套件基于真实 Git 元数据 fixture(测试前置数据),使用 mock 适配器驱动真实 agent loop(智能体循环):覆盖一个 staging worktree、嵌套其中的任务 worktree、普通克隆、位于 staging 命名分支上的其他仓库、HEAD 分离状态、绝对和相对 `gitdir:` 指针、指向同一仓库的符号链接路径、格式错误的指针以及不可读元数据,使两个源码文件都达到逐文件 100% 覆盖率。配套的 `invariant.ts` 会验证持久拒绝的结构,因为拒绝文本是该包唯一面向模型的输出,且只有其中列出路径、分支和 skill 时才具有可操作性。 - -真实组合冒烟测试通过 Loader 与 headless 应用启动 `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml`,并对组装后的运行断言三项事实:工具结果是错误、文本与拒绝理由逐字一致、目标文件仍保留原始字节。这证明系统在分发前强制执行规则,而不是事后给出建议。 - -一个 ACP(Agent Client Protocol)快照场景(`source-guard-staging-deny`)最初负责组装后的 transcript(文本记录),通过新的 `Scenario.prepareCwd` 钩子在 harness 生成的 cwd 中植入 staging worktree——Git 永远不会跟踪名为 `.git` 的条目,且 `.gitignore` 会排除所有 `worktrees/` 目录,因此 fixture 提交两个 `HEAD` 的内容,由钩子组装真实布局。编写它立刻证明了投入的价值:它暴露了上述两个路径缺陷(transcript 显示每当 guard 悄然不作判断时 `fs-policy` 都会率先响应),随后又发现了自身首版 fixture 的问题——被忽略的 `worktrees/` 路径因未跟踪文件而在本地通过。该场景后来被移除,组装运行证据合并进 Loader 组合冒烟测试;它引入的 `prepareCwd` 钩子仍留在快照 harness 中,服务于仓库形态的 fixture。 - -## Related - -- [个人 staging 维护 skill 的 Agent Note](../process/2026-07-23-personal-staging-maintenance-skills.md):本门禁负责执行该工作流的一条规则。对方 Agent Note 负责这些 skill 的内容与发现机制;本文只负责强制执行点,对工作流本身不具有定义权。 -- [拦截 seam Agent Note](2026-06-30-interception-seams.md):本门禁拒绝时使用的 `tools/pre-execute` `allow`/`deny`/`ask` 词汇。 -- [repeat-tool-guard Agent Note](../../archived/feature/2026-07-08-repeat-tool-guard.md):同类 guard;本文刻意不采用其建议性形态。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 4a054fa559..ddbef2cd03 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -76,7 +76,6 @@ "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", - "@deepseek-ai/dsh-source-guard": "workspace:^", "@deepseek-ai/dsh-spill-local": "workspace:^", "@deepseek-ai/dsh-spill-policy": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", @@ -89,7 +88,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-timeout-policy": "workspace:^", - "@deepseek-ai/dsh-tmux-context": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", diff --git a/apps/cli/tui.cordis.yml b/apps/cli/tui.cordis.yml index 0a6f2213c1..13b8c8930d 100644 --- a/apps/cli/tui.cordis.yml +++ b/apps/cli/tui.cordis.yml @@ -97,9 +97,6 @@ # Refuses write/edit inside the dsh checkout this launcher runs from, on that # checkout's own branch, until the session loads dsh-customize. Inert # everywhere else, so an ordinary project sees no change. - - id: source-guard - name: '@deepseek-ai/dsh-source-guard' - - id: tool-result-prune name: '@deepseek-ai/dsh-compact-tool-result-prune' diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 35280954e7..3c0543e4ca 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -108,7 +108,7 @@ export interface Config { Depends on: [`AgentOptions`](core-data-structures/core.md) · [`SessionId`](core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:211`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:155`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-agent-spine-demo` @@ -1153,26 +1153,6 @@ export interface Config { Source: [`packages/context/session-reference/src/config.ts:11`](../packages/context/session-reference/src/config.ts) -## `@deepseek-ai/dsh-session-registry-file` - -```ts config-catalog -/** - * Plugin config as callers write it: `root` is required — a cwd fallback would - * scatter registries — while the lock tunables are optional because - * `static Config` supplies their defaults. - */ -export interface Config { - /** Directory holding the registry file; created `0o700` on demand. */ - root: string - /** Milliseconds after which a held lock is considered abandoned and reclaimed. */ - lockStaleMs?: number - /** Retries before a contended acquisition fails loud. */ - lockRetries?: number -} -``` - -Source: [`packages/session-registry/session-registry-file/src/index.ts:43`](../packages/session-registry/session-registry-file/src/index.ts) - ## `@deepseek-ai/dsh-session-telemetry-otel` Requires: `sessions` @@ -1283,38 +1263,6 @@ export interface Config { Source: [`packages/skill/skill-local/src/index.ts:41`](../packages/skill/skill-local/src/index.ts) -## `@deepseek-ai/dsh-source-guard` - -Requires: `fs` - -```ts config-catalog -/** - * Plugin config, validated by the same-named schemastery schema plus the - * load-time checks in `apply` (misconfiguration fails loud: an empty `tools` - * list, a blank `requiredSkill`, or a relative `protectedCheckout` throws at - * plugin load, never a silent fall-back). - */ -export interface Config { - /** Skill whose loaded presence in the session lifts the denial (default `dsh-customize`). */ - requiredSkill?: string - /** Tool names to gate (default `['write', 'edit']`). */ - tools?: string[] - /** - * Absolute path inside the checkout this guard protects. Its worktree - * supplies BOTH protected identities: the repository (targets in any other - * repository are ignored) and the exact branch (only that branch's worktree - * is protected). Defaults to this module's own location, which resolves the - * checkout the running harness was launched from — the live deployment, - * whatever its branch is named. Set it explicitly to guard a different - * checkout, or when the harness runs from an installed copy whose own - * location is not a checkout at all. - */ - protectedCheckout?: string -} -``` - -Source: [`packages/guard/source-guard/src/index.ts:30`](../packages/guard/source-guard/src/index.ts) - ## `@deepseek-ai/dsh-spill-local` ```ts config-catalog @@ -1593,20 +1541,6 @@ export interface Config { Source: [`packages/context/time-context/src/index.ts:20`](../packages/context/time-context/src/index.ts) -## `@deepseek-ai/dsh-tmux-context` - -Requires: `agents` - -```ts config-catalog -/** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ -export interface Config { - /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible change. */ - refreshIntervalMs?: number -} -``` - -Source: [`packages/context/tmux-context/src/index.ts:33`](../packages/context/tmux-context/src/index.ts) - ## `@deepseek-ai/dsh-token-meter` ```ts config-catalog @@ -2005,6 +1939,63 @@ export interface TuiThemeConfig { Source: [`packages/ui/tui/src/config.ts:117`](../packages/ui/tui/src/config.ts) +## `@deepseek-ai/dsh-tui-demo` + +```ts config-catalog +/** 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 + /** 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 + /** Generic background-task controls forwarded through agent-spine-demo; set false to omit them. */ + toolTasks?: NonNullable + /** 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'] +} +``` + +Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`SessionReferenceConfig`](#deepseek-aidsh-session-reference) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) + +Source: [`packages/examples/tui-demo/src/index.ts:39`](../packages/examples/tui-demo/src/index.ts) + ## `@deepseek-ai/dsh-user-approval` ```ts config-catalog @@ -2240,7 +2231,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-session-projection` ([`packages/session-projection/session-projection/src/index.ts`](../packages/session-projection/session-projection/src/index.ts)) -- `@deepseek-ai/dsh-session-registry-live` — requires `sessions` · `sessionRegistry` ([`packages/session-registry/session-registry-live/src/index.ts`](../packages/session-registry/session-registry-live/src/index.ts)) - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts)) @@ -2263,7 +2253,6 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) - `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts)) -- `@deepseek-ai/dsh-session-registry` — abstract `SessionRegistry` ([`packages/session-registry/session-registry/src/index.ts`](../packages/session-registry/session-registry/src/index.ts)) - `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts)) - `@deepseek-ai/dsh-subprocess` — abstract `SubprocessService` ([`packages/subprocess/subprocess/src/index.ts`](../packages/subprocess/subprocess/src/index.ts)) - `@deepseek-ai/dsh-tasks` — abstract `TaskService` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts)) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 6048296973..71238305e5 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:157`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:148`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | | `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:286`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:218`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:227`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | @@ -21,7 +21,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:299`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | | `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:387`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:236`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:373`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | @@ -31,9 +31,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | @@ -50,7 +50,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:125`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:102`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`source-guard`](../packages/guard/source-guard), [`tool-tasks`](../packages/tasks/tool-tasks) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:102`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | @@ -65,7 +65,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | | `commands/changed` | `runtime` (`emit`) | `ui-command` | | `connection/reset` | `runtime` (`emit`) | `ui-command` | -| `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`source-guard`](../packages/guard/source-guard), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | +| `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `modules`, `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-settings-general` | diff --git a/docs/module-graph.md b/docs/module-graph.md index 3c928282ee..d3e60978e7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -173,7 +173,6 @@ flowchart TD subgraph group_context["packages/context"] pkg_session_reference["session-reference"] pkg_time_context["time-context"] - pkg_tmux_context["tmux-context"] pkg_workspace_context["workspace-context"] end subgraph group_examples["packages/examples"] @@ -181,10 +180,10 @@ flowchart TD pkg_agent_spine_demo["agent-spine-demo"] pkg_cli_demo["cli-demo"] pkg_jsonrpc_demo["jsonrpc-demo"] + pkg_tui_demo["tui-demo"] end subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] - pkg_source_guard["source-guard"] end subgraph group_host["packages/host"] pkg_host_apiproxy["host-apiproxy"] @@ -222,11 +221,6 @@ flowchart TD pkg_session_projection["session-projection"] pkg_session_projection_cache["session-projection-cache"] end - subgraph group_session_registry["packages/session-registry"] - pkg_session_registry["session-registry"] - pkg_session_registry_file["session-registry-file"] - pkg_session_registry_live["session-registry-live"] - end subgraph group_storage["packages/storage"] pkg_storage["storage"] pkg_storage_domain["storage-domain"] @@ -456,9 +450,6 @@ flowchart TD pkg_sandbox_policy --> pkg_session pkg_session_projection --> pkg_invariants pkg_session_projection --> pkg_session - pkg_session_registry --> pkg_brand - pkg_session_registry --> pkg_invariants - pkg_session_registry --> pkg_session pkg_llm_retry --> pkg_agent pkg_llm_retry --> pkg_invariants pkg_llm_retry --> pkg_llm @@ -532,10 +523,6 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session - pkg_tmux_context --> pkg_agent - pkg_tmux_context --> pkg_bash - pkg_tmux_context --> pkg_invariants - pkg_tmux_context --> pkg_session pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants @@ -546,9 +533,6 @@ flowchart TD pkg_session_projection_cache --> pkg_session_persistence pkg_session_projection_cache --> pkg_session_projection pkg_session_projection_cache --> pkg_storage_domain - pkg_session_registry_file --> pkg_invariants - pkg_session_registry_file --> pkg_session - pkg_session_registry_file --> pkg_session_registry pkg_tasks --> pkg_agent pkg_tasks --> pkg_brand pkg_tasks --> pkg_invariants @@ -630,10 +614,6 @@ flowchart TD pkg_pty_local --> pkg_sandbox_policy pkg_pty_local --> pkg_session pkg_pty_local --> pkg_subprocess - pkg_session_registry_live --> pkg_invariants - pkg_session_registry_live --> pkg_session - pkg_session_registry_live --> pkg_session_registry - pkg_session_registry_live --> pkg_session_title pkg_tasks_local --> pkg_agent pkg_tasks_local --> pkg_invariants pkg_tasks_local --> pkg_tasks @@ -796,13 +776,6 @@ flowchart TD pkg_repeat_tool_guard --> pkg_agent pkg_repeat_tool_guard --> pkg_invariants pkg_repeat_tool_guard --> pkg_tools - pkg_source_guard --> pkg_agent - pkg_source_guard --> pkg_fs - pkg_source_guard --> pkg_invariants - pkg_source_guard --> pkg_llm - pkg_source_guard --> pkg_sandbox - pkg_source_guard --> pkg_session - pkg_source_guard --> pkg_tools pkg_tool_lsp --> pkg_invariants pkg_tool_lsp --> pkg_llm pkg_tool_lsp --> pkg_lsp @@ -962,6 +935,24 @@ flowchart TD pkg_cli_demo --> pkg_session_persistence_jsonl pkg_cli_demo --> pkg_tools pkg_cli_demo --> pkg_workspace_context + pkg_tui_demo --> pkg_agent + pkg_tui_demo --> pkg_agent_loop + pkg_tui_demo --> pkg_agent_spine_demo + pkg_tui_demo --> pkg_command_goal + pkg_tui_demo --> pkg_commands + pkg_tui_demo --> pkg_invariants + pkg_tui_demo --> pkg_llm + pkg_tui_demo --> pkg_session + pkg_tui_demo --> pkg_session_checkpoint_policy + pkg_tui_demo --> pkg_session_persistence_jsonl + pkg_tui_demo --> pkg_session_query + pkg_tui_demo --> pkg_session_query_sqlite + pkg_tui_demo --> pkg_session_reference + pkg_tui_demo --> pkg_tool_ask_user + pkg_tui_demo --> pkg_tools + pkg_tui_demo --> pkg_tui + pkg_tui_demo --> pkg_user_interaction + pkg_tui_demo --> pkg_workspace_context pkg_sdk_client --> pkg_invariants pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol @@ -1054,7 +1045,6 @@ flowchart TD | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`session-registry`](../packages/session-registry/session-registry) | `session-registry` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1072,11 +1062,9 @@ flowchart TD | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`storage-domain`](../packages/storage/storage-domain) | -| [`session-registry-file`](../packages/session-registry/session-registry-file) | `session-registry` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-registry`](../packages/session-registry/session-registry) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-telemetry`](../packages/telemetry/session-telemetry) | `telemetry` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1092,7 +1080,6 @@ flowchart TD | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | -| [`session-registry-live`](../packages/session-registry/session-registry-live) | `session-registry` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-registry`](../packages/session-registry/session-registry), [`session-title`](../packages/session-title/session-title) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1120,7 +1107,6 @@ flowchart TD | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | -| [`source-guard`](../packages/guard/source-guard) | `guard` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | @@ -1141,5 +1127,6 @@ flowchart TD | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`session-reference`](../packages/context/session-reference), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml b/examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml deleted file mode 100644 index a8a83302bb..0000000000 --- a/examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml +++ /dev/null @@ -1,38 +0,0 @@ -# Test-only composition: the model attempts one `write` into a staging-shaped -# git fixture, so the guard's denial is observed through the real Loader and app. -- id: source-guard-mock-llm - name: './mock-llm.ts' - -# Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' - -- id: bash - name: '@deepseek-ai/dsh-bash-local' - -- id: fs - name: '@deepseek-ai/dsh-fs-local' - -# Read-before-edit policy: without it the write would resolve `createIfAbsent` -# and the transcript would not show the guard as the sole reason for refusal. -- id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - -- id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' - -# Mounts the guard with `protectedCheckout` resolved against the process cwd, so -# it arms for the staging fixture the smoke builds there rather than for the -# checkout running the test (the config default is this module's own location). -- id: source-guard-fixture - name: './mount-guard.ts' - -- id: cli-agent - name: '@deepseek-ai/dsh-cli-demo' - config: - provider: source-guard-mock - model: source-guard-mock - persona: 'Test the source guard.' - persistenceRoot: './.sessions' - persistenceCompression: none - workspaceContext: false diff --git a/examples/headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts b/examples/headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts deleted file mode 100644 index 44baccd617..0000000000 --- a/examples/headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { resolve } from 'node:path' -import type { Context } from 'cordis' -import { CallId, LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' - -/** The staged file the smoke builds in the process cwd; the guard must refuse to write it. */ -const TARGET = resolve('staging/guarded.ts') - -/** - * Two-step adapter for the source-guard Loader fixture: the first step calls - * `write` on the staged file, the second closes the turn once a tool result has - * come back, so the transcript records what the model received. - */ -class SourceGuardMockAdapter extends LlmAdapter { - async * stream(options: GenerateOptions): AsyncIterable { - const alreadyCalled = options.messages.some(message => message.content.some( - block => block.type === 'tool-result', - )) - if (alreadyCalled) { - const text = 'denied as expected' - yield { type: 'block-start', index: 0, blockType: 'text' } - yield { type: 'text-delta', index: 0, text } - yield { type: 'block-end', index: 0, block: { type: 'text', text } } - yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } } - yield { type: 'finish', reason: { kind: 'stop' } } - return - } - const callId = CallId('source-guard-write') - const args = JSON.stringify({ file_path: TARGET, content: 'edited\n' }) - yield { type: 'block-start', index: 0, blockType: 'tool-call' } - yield { type: 'tool-call-delta', index: 0, id: callId, name: 'write', argumentsDelta: args } - yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: callId, name: 'write', arguments: args } } - yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } } - yield { type: 'finish', reason: { kind: 'tool-calls' } } - } -} - -export const name = 'source-guard-mock-llm' -export const inject = ['llm'] - -/** Register the test-only `source-guard-mock` adapter. */ -export function apply(ctx: Context): void { - ctx.llm.registerAdapter(['source-guard-mock'], new SourceGuardMockAdapter()) -} diff --git a/examples/headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts b/examples/headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts deleted file mode 100644 index 90a89a2897..0000000000 --- a/examples/headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { resolve } from 'node:path' -import type { Context } from 'cordis' -import * as SourceGuard from '@deepseek-ai/dsh-source-guard' - -export const name = 'source-guard-fixture' - -/** - * Mount the real guard against the staging fixture in the process cwd. The - * checkout under protection is a runtime fact of the isolated smoke directory, - * which no static config value can name. - */ -export async function apply(ctx: Context): Promise { - await ctx.plugin(SourceGuard, { protectedCheckout: resolve('staging/guard-anchor.ts') }) -} diff --git a/examples/headless-agent/tests/fixtures/tmux-context-driver.ts b/examples/headless-agent/tests/fixtures/tmux-context-driver.ts deleted file mode 100644 index 2fd6d0f5ec..0000000000 --- a/examples/headless-agent/tests/fixtures/tmux-context-driver.ts +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env node -/** Test driver that sends two turns through one Headless Loader composition. */ - -import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' -import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts' - -const configPath = process.argv[2] -if (configPath === undefined) throw new Error('tmux-context driver requires a config path') - -const ctx = await boot('tmux-context-e2e', resolveConfigPath(configPath, undefined)) -try { - await runOneShot(ctx, { task: 'first' }) - await runOneShot(ctx, { task: 'second' }) -} finally { - await ctx.fiber.dispose() -} diff --git a/examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts b/examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts deleted file mode 100644 index 3e9540abff..0000000000 --- a/examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Context } from 'cordis' -import { BashExecutor } from '@deepseek-ai/dsh-bash' -import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' - -/** - * Deterministic `ctx.bash` for the tmux-context Loader fixture: any command - * (the plugin's `tmux display-message`) returns a fixed tab-delimited reading, - * so the injected tmux location is stable without a real tmux server. `start()` - * throws — tmux-context must never spawn a background process. - */ -class TmuxMockBash extends BashExecutor { - override resolve(request: BashExecRequest): BashExecSpec { - return { - command: request.command, - workdir: request.workdir ?? process.cwd(), - timeoutMs: request.timeoutMs ?? 60_000, - stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, - signal: request.signal, - sandboxPolicy: request.sandboxPolicy, - } - } - - override run(_spec: BashExecSpec): Promise { - const line = ['work', '0', 'editor', '1', '%3', '1', '1', 'a1b2,80x24,0,0,4'].join('\\t') - return Promise.resolve({ - exitCode: 0, - signal: null, - timedOut: false, - aborted: false, - timeoutMs: 60_000, - stdout: { text: `${line}\n`, truncated: false }, - stderr: { text: '', truncated: false }, - }) - } - - override start(): BashProcess { - throw new Error('tmux-context must never start a background task') - } -} - -export const name = 'tmux-context-mock-bash' - -/** Register the deterministic `ctx.bash` executor for the fixture. */ -export function apply(ctx: Context): void { - ctx.plugin(TmuxMockBash) -} diff --git a/examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts b/examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts deleted file mode 100644 index 2f6c7a6408..0000000000 --- a/examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Context } from 'cordis' -import { LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm' - -/** Deterministic one-step adapter for the tmux-context Loader fixture. */ -class TmuxContextMockAdapter extends LlmAdapter { - async * stream(): AsyncIterable { - const text = 'tmux context sampled' - yield { type: 'block-start', index: 0, blockType: 'text' } - yield { type: 'text-delta', index: 0, text } - yield { type: 'block-end', index: 0, block: { type: 'text', text } } - yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } } - yield { type: 'finish', reason: { kind: 'stop' } } - } -} - -export const name = 'tmux-context-mock-llm' -export const inject = ['llm'] - -/** Register the test-only `tmux-context-mock` adapter. */ -export function apply(ctx: Context): void { - ctx.llm.registerAdapter(['tmux-context-mock'], new TmuxContextMockAdapter()) -} diff --git a/examples/headless-agent/tests/fixtures/tmux-context.cordis.yml b/examples/headless-agent/tests/fixtures/tmux-context.cordis.yml deleted file mode 100644 index 419922a0e3..0000000000 --- a/examples/headless-agent/tests/fixtures/tmux-context.cordis.yml +++ /dev/null @@ -1,21 +0,0 @@ -# Test-only composition: keep tmux-context opt-in while exercising its real Loader/app path. -# A deterministic mock ctx.bash returns a fixed tmux reading, so the injected location -# is stable without a real tmux server on the test host. -- id: tmux-context-mock-llm - name: './tmux-context-mock-llm.ts' - -- id: bash - name: './tmux-context-mock-bash.ts' - -- id: tmux-context - name: '@deepseek-ai/dsh-tmux-context' - -- id: cli-agent - name: '@deepseek-ai/dsh-cli-demo' - config: - provider: tmux-context-mock - model: tmux-context-mock - persona: 'Test the tmux-context plugin.' - persistenceRoot: './.sessions' - persistenceCompression: 'none' - workspaceContext: false diff --git a/examples/package.json b/examples/package.json index 88e1e8f57a..1de7ab71c1 100644 --- a/examples/package.json +++ b/examples/package.json @@ -56,7 +56,6 @@ "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:*", "@deepseek-ai/dsh-skill": "workspace:*", "@deepseek-ai/dsh-skill-local": "workspace:*", - "@deepseek-ai/dsh-source-guard": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", @@ -69,7 +68,6 @@ "@deepseek-ai/dsh-tasks-local": "workspace:*", "@deepseek-ai/dsh-time-context": "workspace:*", "@deepseek-ai/dsh-timeout-policy": "workspace:*", - "@deepseek-ai/dsh-tmux-context": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-tool-ask-user": "workspace:*", "@deepseek-ai/dsh-tool-bash": "workspace:*", diff --git a/knip.json b/knip.json index 1db0b5d257..e40f343dff 100644 --- a/knip.json +++ b/knip.json @@ -33,15 +33,10 @@ "headless-agent/tests/fixtures/semantic-checkpoint-agent.ts", "headless-agent/tests/fixtures/subagent-inheritance-agent.ts", "headless-agent/tests/fixtures/goal-domain/seed-goal.ts", - "headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts", - "headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts", "headless-agent/tests/fixtures/time-context-driver.ts", "headless-agent/tests/fixtures/time-context-mock-llm.ts", "headless-agent/tests/fixtures/telemetry-otel-driver.ts", "headless-agent/tests/fixtures/telemetry-redact-rule.ts", - "headless-agent/tests/fixtures/tmux-context-driver.ts", - "headless-agent/tests/fixtures/tmux-context-mock-llm.ts", - "headless-agent/tests/fixtures/tmux-context-mock-bash.ts", "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts", @@ -178,16 +173,6 @@ "tests/**/*.ts" ] }, - "packages/context/tmux-context": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, "packages/lsp/lsp-local": { "entry": [ "tests/**/*.spec.ts", @@ -303,16 +288,6 @@ "tests/**/*.ts" ] }, - "packages/guard/source-guard": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, "packages/session-registry/session-registry-file": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/context/README.i18n.yaml b/packages/context/README.i18n.yaml index cb3398b919..de3bc8c70f 100644 --- a/packages/context/README.i18n.yaml +++ b/packages/context/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/context/README.md -README.md: a5244dfe99a714605744b57d33f97359d4d6fa4e -README.zh.md: 6036d58c6937d025adc36d9b2d12f51e396ee3d0 +README.md: bc3237b98732c23e6a2b120e055f7713b91f9b7c +README.zh.md: b195a4c0b96b1f6f0b66bc6efa99a4a66b3c2fa2 diff --git a/packages/context/README.md b/packages/context/README.md index ffe9aaa9e2..bc3237b987 100644 --- a/packages/context/README.md +++ b/packages/context/README.md @@ -2,13 +2,12 @@ English | [中文](README.zh.md) -Product plugins that add model-visible request context without defining a tool. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context` and `tmux-context` are opt-in, while the standard TUI bundle composes `session-reference` explicitly. +Product plugins that add model-visible request context without defining a tool. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context` is opt-in, while the standard TUI bundle composes `session-reference` explicitly. | Package | Role | ctx key | |---|---|---| | `session-reference/` | Bounded current-surface snapshots of other sessions | `ctx.sessionReferences` | | `time-context/` | Durable per-step current time and elapsed-time context | (none) | -| `tmux-context/` | Durable per-turn context with this agent's tmux pane/window location | (listens on `agent/pre-step`, reads `ctx.bash`) | | `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/session-prefix` + `tools/post-execute`) | The [`workspace-context` decision record](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) explains its per-agent/session isolation and lifecycle split. diff --git a/packages/context/README.zh.md b/packages/context/README.zh.md index 6036d58c69..b195a4c0b9 100644 --- a/packages/context/README.zh.md +++ b/packages/context/README.zh.md @@ -2,12 +2,12 @@ [English](README.md) | 中文 -这些产品插件无需定义工具,即可增加模型可见的请求上下文。`workspace-context` 包含在默认的 `dsh-agent-spine-demo` 组合包中,且可通过组合包配置将其禁用;`time-context` 需显式启用,标准 TUI 组合包则会显式组合 `session-reference`。 +这些产品插件无需定义工具,即可增加模型可见的请求上下文。`workspace-context` 包含在默认的 `dsh-agent-spine-demo` 组合包中,且可通过组合包配置将其禁用;`time-context` 需要选择启用,标准 TUI 组合包则会显式组合 `session-reference`。 | 包 | 职责 | ctx key | |---|---|---| | `session-reference/` | 其他会话当前表层的有界快照 | `ctx.sessionReferences` | -| `time-context/` | 持久化的逐步骤当前时间与已用时上下文 | (无) | -| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` 工作区上下文 loader | (监听 `agent/step` + `tools/post-execute`) | +| `time-context/` | 持久的逐步骤当前时间与耗时上下文 | (无) | +| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` 工作区上下文 loader | (监听 `agent/session-prefix` + `tools/post-execute`) | -[`workspace-context` 决策记录](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)解释了每个 agent(智能体)和会话各自隔离的方式,以及相应的生命周期拆分。 +[`workspace-context` 决策记录](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)解释了它的逐 agent/会话隔离与生命周期拆分。 diff --git a/packages/context/tmux-context/README.i18n.yaml b/packages/context/tmux-context/README.i18n.yaml deleted file mode 100644 index ec4562eea4..0000000000 --- a/packages/context/tmux-context/README.i18n.yaml +++ /dev/null @@ -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/context/tmux-context/README.md -README.md: 5ea36948d6d83135c5aa97650c0d77e942adbbaa -README.zh.md: 914d8d7c99c37de2c64541bcf4968996d819077d diff --git a/packages/context/tmux-context/README.md b/packages/context/tmux-context/README.md deleted file mode 100644 index 5ea36948d6..0000000000 --- a/packages/context/tmux-context/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# @deepseek-ai/dsh-tmux-context - -English | [中文](README.zh.md) - -Opt-in durable context naming the tmux session, window, and pane this agent process runs in, plus the window's pane-tree layout. Sampled once per turn during model-request preparation. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md). - -## Config - -```yaml -- id: tmux-context - name: '@deepseek-ai/dsh-tmux-context' - config: - refreshIntervalMs: 60000 # optional; omit or set to 0 to inject on every changed turn -``` - -`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` injects whenever the tmux state changed since the last injection. A positive value additionally suppresses injections that fall within that many milliseconds of the latest one. - -## How it reads tmux - -The plugin prepends an `agent/step` listener that runs only on the first step of each turn. When due, it runs one read-only command through the `ctx.bash` executor seam: - -```sh -[ -n "$TMUX_PANE" ] || exit 1 -self_tty=$(ps -o tty= -p | tr -d ' ') -pane_tty=$(tmux display-message -t "$TMUX_PANE" -p '#{pane_tty}') || exit 1 -[ "$pane_tty" = "/dev/$self_tty" ] || exit 1 -exec tmux display-message -t "$TMUX_PANE" -p '' -``` - -`$TMUX_PANE` alone is insufficient: a terminal launched from a tmux shell (a VS Code integrated terminal, a desktop launcher) **inherits** `$TMUX` and `$TMUX_PANE` from that ancestor, so the variables are present even though the process does not live in that pane. The command therefore also compares the pane's `#{pane_tty}` against this process's own controlling terminal (`ps -o tty=` for its pid): a genuine pane owns this process's tty, while an inherited environment names some other pane's tty. Running through `ctx.bash` applies the deployment's sandbox and policy; the plugin owns no subprocess code. When `ctx.bash` is absent, the process is not in a real tmux pane (`$TMUX_PANE` unset, or the tty does not match ⇒ nonzero exit), or the reading is malformed, the attempt is a no-op, never an error. - -State is pulled on every eligible turn — a moved, renamed, or re-laid-out pane is picked up without any tmux hook or background process. The plugin re-injects only when the rendered tmux state differs from its last injection, so an unchanged location adds nothing. - -## Timing semantics - -When an injection is due, the plugin appends one injected `user/message` through `agent.inject()` before `step/start`, with source `{ kind: 'plugin', plugin: 'tmux-context' }`. Change suppression and interval scheduling scan the raw durable session events for the latest injection of this source, so the schedule survives compaction and resumed processes without process-local cache state; sessions schedule independently. The reading records a request-preparation attempt, not a committed step; because the listener runs first, its append may remain when a later pre-step listener cancels or fails the attempt (the log is append-only and the plugin performs no rollback). - -## Model Experience - -### Preparation-time tmux location - -#### What the model sees - -On each turn whose tmux state changed, one source-tagged context message with the three lines below. `` is tmux's compact pane-tree description; pane and window pixel sizes are intentionally excluded, and the contents of sibling panes are never captured. - -##### Changed-turn reading - -```markdown -tmux location (turn ): -session , window "", pane -window active=<0|1>, pane active=<0|1>, layout -``` - -#### Token effect - -Each two-line reading accumulates until compaction shadows it. Unchanged locations and interval suppression add nothing. - -#### KV Cache effect - -Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. - -## Known Limitations and Deferred Work - -- **First step only** — a pane moved or resized mid-turn is reflected on the next turn, not between steps. -- **Own location only** — the plugin never captures the visible text of sibling panes. -- **Layout, not size** — pane/window pixel dimensions are omitted; only the layout tree and active flags are reported. -- **Tab-delimited fields** — a tmux window name containing the literal two-character sequence `\t` would mis-split the reading and be skipped as malformed; ordinary names are unaffected. -- **tty-based pane detection** — the process is considered "in tmux" only when its controlling terminal matches `$TMUX_PANE`'s `#{pane_tty}`. This deliberately excludes terminals that inherited `$TMUX`/`$TMUX_PANE` from a tmux ancestor (e.g. a VS Code integrated terminal). `ps -o tty=` is POSIX; the check is a no-op wherever it or `#{pane_tty}` is unavailable. diff --git a/packages/context/tmux-context/README.zh.md b/packages/context/tmux-context/README.zh.md deleted file mode 100644 index 914d8d7c99..0000000000 --- a/packages/context/tmux-context/README.zh.md +++ /dev/null @@ -1,68 +0,0 @@ -# @deepseek-ai/dsh-tmux-context - -[English](README.md) | 中文 - -可选启用的持久上下文,记录本 agent 进程所在的 tmux session、window、pane,以及该 window 的 pane 树布局。在准备模型请求时每轮采样一次。`dsh-agent-spine-demo` 与随附示例均不挂载它。决策记录见:[tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md)。 - -## 配置 - -```yaml -- id: tmux-context - name: '@deepseek-ai/dsh-tmux-context' - config: - refreshIntervalMs: 60000 # optional; omit or set to 0 to inject on every changed turn -``` - -`refreshIntervalMs` 必须是非负安全整数。省略或 `0` 表示只要 tmux 状态自上次注入以来发生变化就注入。正值会额外抑制距最近一次注入不足该毫秒数的注入。 - -## 如何读取 tmux - -插件前置注册一个 `agent/step` 监听器,仅在每轮的第一个 step 运行。当需要注入时,它通过 `ctx.bash` 执行器 seam 运行一条只读命令: - -```sh -[ -n "$TMUX_PANE" ] || exit 1 -self_tty=$(ps -o tty= -p | tr -d ' ') -pane_tty=$(tmux display-message -t "$TMUX_PANE" -p '#{pane_tty}') || exit 1 -[ "$pane_tty" = "/dev/$self_tty" ] || exit 1 -exec tmux display-message -t "$TMUX_PANE" -p '' -``` - -仅凭 `$TMUX_PANE` 并不足够:从 tmux shell 启动的终端(VS Code 集成终端、桌面启动器)会从该祖先进程**继承** `$TMUX` 与 `$TMUX_PANE`,因此即使进程并不位于那个 pane 中,这些变量依然存在。为此该命令还会把 pane 的 `#{pane_tty}` 与本进程自己的控制终端(对其 pid 执行 `ps -o tty=`)作比较:真正的 pane 拥有本进程的 tty,而继承而来的环境指向的是另一个 pane 的 tty。通过 `ctx.bash` 运行会应用部署方的沙箱与策略;插件不拥有任何子进程代码。当 `ctx.bash` 缺失、进程不在真实的 tmux pane 内(`$TMUX_PANE` 未设置,或 tty 不匹配 ⇒ 非零退出)或读取结果格式非法时,本次尝试为空操作,绝不报错。 - -状态在每个符合条件的轮次拉取——pane 被移动、改名或重新布局都会被感知,无需任何 tmux hook 或后台进程。插件仅在渲染出的 tmux 状态与上次注入不同时才重新注入,因此位置不变时不会新增任何内容。 - -## 时序语义 - -当需要注入时,插件在 `step/start` 之前通过 `agent.inject()` 追加一条注入的 `user/message`,来源为 `{ kind: 'plugin', plugin: 'tmux-context' }`。变化抑制与间隔调度会扫描原始持久会话事件中该来源的最近一次注入,因此调度可跨压缩与恢复的进程存续,无需进程内缓存状态;各会话独立调度。该读数记录的是一次请求准备尝试,而非已提交的 step;由于监听器最先运行,当后续 pre-step 监听器取消或失败时,它的追加可能仍会保留(日志只追加,插件不做回滚)。 - -## 模型体验 - -### 准备期 tmux 位置 - -#### 模型看到的内容 - -在 tmux 状态发生变化的每一轮,注入一条带来源标记、含以下三行的上下文消息。`` 是 tmux 紧凑的 pane 树描述;pane 与 window 的像素尺寸有意省略,相邻 pane 的内容从不采集。 - -##### 变化轮次读数 - -```markdown -tmux location (turn ): -session , window "", pane -window active=<0|1>, pane active=<0|1>, layout -``` - -#### Token 影响 - -每条两行读数会累积,直到压缩将其遮蔽。位置未变化以及间隔抑制不会新增内容。 - -#### KV 缓存影响 - -只追加;新增可见内容位于可复用的请求前缀之后,不会使已有 KV 缓存条目失效。 - -## 已知限制与后续工作 - -- **仅第一个 step**——轮次中途移动或缩放的 pane 会在下一轮反映,而非在 step 之间。 -- **仅自身位置**——插件从不采集相邻 pane 的可见文本。 -- **只有布局,没有尺寸**——省略 pane/window 像素尺寸;仅报告布局树与活动标志。 -- **制表符分隔字段**——若 tmux window 名称包含字面两字符序列 `\t`,会使读数分割错误并作为非法读数跳过;常规名称不受影响。 -- **基于 tty 的 pane 判定**——只有当进程的控制终端与 `$TMUX_PANE` 的 `#{pane_tty}` 一致时,才视为“位于 tmux 中”。这会有意排除从 tmux 祖先进程继承 `$TMUX`/`$TMUX_PANE` 的终端(如 VS Code 集成终端)。`ps -o tty=` 属于 POSIX;在其或 `#{pane_tty}` 不可用的环境中,该检查即为空操作。 diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json deleted file mode 100644 index a874ebb08c..0000000000 --- a/packages/context/tmux-context/package.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-tmux-context", - "description": "Opt-in durable per-step context with this agent's tmux pane and window location", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" - ], - "license": "BSD-3-Clause", - "dependencies": { - "schemastery": "^3.18.0" - }, - "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-bash": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "devDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-bash": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-loader-smoke": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "cordis": "^4.0.0-rc.7" - } -} diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts deleted file mode 100644 index 495ab886a0..0000000000 --- a/packages/context/tmux-context/src/index.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * Opt-in request-preparation tmux-location context. Eligible step attempts - * append durable, source-attributed context naming the tmux session, window, - * and pane this agent process runs in, plus the window's pane-tree layout. - * - * The plugin pulls state once per turn, on the first step (`step === 1`), by - * running one `tmux display-message` through the `ctx.bash` executor seam. It - * confirms this process genuinely runs inside the pane `$TMUX_PANE` names by - * matching the pane's `#{pane_tty}` against this process's controlling terminal, - * so a terminal that merely inherited `$TMUX`/`$TMUX_PANE` from a tmux ancestor - * (e.g. a VS Code integrated terminal) reads as "not in tmux". It re-injects - * only when the rendered tmux state changes since the last injection (a moved, - * renamed, or re-laid-out pane), with an optional `refreshIntervalMs` floor - * between injections. Absent tmux environment, an inherited-only environment, - * absent `ctx.bash`, or a failed query is a no-op, never an error. - * - * @module @deepseek-ai/dsh-tmux-context - */ - -import type { Context } from 'cordis' -import z from 'schemastery' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { BashExecutor } from '@deepseek-ai/dsh-bash' -import { createUserMessage } from '@deepseek-ai/dsh-llm' - -/** Cordis plugin name used by loader diagnostics. */ -export const name = 'tmux-context' - -/** The agent registry that owns the `agent/step` lifecycle seam. */ -export const inject = ['agents'] - -/** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ -export interface Config { - /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible change. */ - refreshIntervalMs?: number -} - -/** Schemastery validation for {@link Config}. */ -export const Config: z = z.object({ - refreshIntervalMs: z.number(), -}) - -/** - * Tab-separated tmux format fields, in query order. Layout (`window_layout`) - * is the pane-tree description; pane/window pixel sizes are intentionally - * excluded (own location and layout only, per the package scope). - */ -const TMUX_FIELDS = [ - '#{session_name}', - '#{window_index}', - '#{window_name}', - '#{pane_index}', - '#{pane_id}', - '#{window_active}', - '#{pane_active}', - '#{window_layout}', -] as const - -/** Structured tmux location parsed from one `display-message` reading. */ -interface TmuxLocation { - sessionName: string - windowIndex: string - windowName: string - paneIndex: string - paneId: string - windowActive: string - paneActive: string - windowLayout: string -} - -/** Prefix marking the volatile turn/step preamble line of a rendered reading. */ -const READING_PREFIX = 'tmux location (turn ' - -/** - * Field separator between tmux format fields. tmux does not interpret C escapes - * in a format, so the literal two-character sequence `\t` is emitted verbatim - * and split back out here; this avoids embedding raw whitespace in the command. - */ -const FIELD_SEP = '\\t' - -/** - * Read this process's tmux location through the bash seam, or `undefined` when - * this process is not genuinely running inside a tmux pane or the query fails. - * - * `$TMUX_PANE` alone is insufficient: a terminal launched from a tmux shell - * (e.g. VS Code's integrated terminal, a desktop launcher) inherits `$TMUX` and - * `$TMUX_PANE` from that ancestor, so the variables are present even though this - * process does not live in that pane. The command therefore also compares the - * pane's `#{pane_tty}` against this process's own controlling terminal - * (`ps -o tty=` for {@link processId}); a genuine pane owns this process's tty, - * an inherited environment names some other pane's tty. Fields are emitted only - * on a match, so an inherited environment reads as "not in tmux" and injects - * nothing. - * - * @param bash - the executor seam used to run the read-only tmux/ps commands. - * @param processId - this agent process's pid, whose controlling tty must match the pane. - * @param signal - abort signal forwarded to the executor. - * @returns the parsed location, or `undefined` when not in a real pane or on any failure. - */ -async function queryTmuxLocation( - bash: BashExecutor, - processId: number, - signal: AbortSignal, -): Promise { - const format = TMUX_FIELDS.join(FIELD_SEP) - const command = [ - '[ -n "$TMUX_PANE" ] || exit 1', - `self_tty=$(ps -o tty= -p ${processId} | tr -d ' ')`, - '[ -n "$self_tty" ] || exit 1', - 'pane_tty=$(tmux display-message -t "$TMUX_PANE" -p \'#{pane_tty}\') || exit 1', - '[ "$pane_tty" = "/dev/$self_tty" ] || exit 1', - `exec tmux display-message -t "$TMUX_PANE" -p '${format}'`, - ].join('\n') - const spec = bash.resolve({ command, signal }) - const result = await bash.run(spec) - if (result.exitCode !== 0) return undefined - const line = result.stdout.text.split('\n', 1)[0] as string - const parts = line.split(FIELD_SEP) - if (parts.length !== TMUX_FIELDS.length) return undefined - const [ - sessionName, - windowIndex, - windowName, - paneIndex, - paneId, - windowActive, - paneActive, - windowLayout, - ] = parts as [string, string, string, string, string, string, string, string] - if (paneId.length === 0) return undefined - return { - sessionName, - windowIndex, - windowName, - paneIndex, - paneId, - windowActive, - paneActive, - windowLayout, - } -} - -/** - * Render the stable tmux state block: the part of a reading compared for - * change suppression. It excludes the turn preamble so re-injection is driven - * only by tmux state, not by loop position. - */ -function renderState(location: TmuxLocation): string { - return `session ${location.sessionName}, ` - + `window ${location.windowIndex} ${JSON.stringify(location.windowName)}, ` - + `pane ${location.paneIndex} ${location.paneId}\n` - + `window active=${location.windowActive}, pane active=${location.paneActive}, ` - + `layout ${location.windowLayout}` -} - -/** Render the full durable reading, including the volatile turn preamble. */ -function renderReading(location: TmuxLocation, turn: number): string { - return `${READING_PREFIX}${turn}):\n${renderState(location)}` -} - -/** - * The stable state block of this plugin's latest durable injection, or - * `undefined` when the session has none. Scans raw durable events so the - * schedule survives compaction and resumed processes without process-local - * cache state. - */ -function latestInjectedState(agent: Agent): { state: string; time: number } | undefined { - for (const event of [...agent.session.events].reverse()) { - if (event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === name) { - const [block] = event.data.content - if (block?.type !== 'text') return undefined - const newline = block.text.indexOf('\n') - const state = newline === -1 ? '' : block.text.slice(newline + 1) - return { state, time: event.time } - } - } - return undefined -} - -/** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */ -function validateRefreshInterval(refreshIntervalMs: number | undefined): void { - if (refreshIntervalMs !== undefined && ( - !Number.isSafeInteger(refreshIntervalMs) - || refreshIntervalMs < 0 - )) { - throw new TypeError( - `tmux-context: refreshIntervalMs must be a non-negative safe integer, got ${String(refreshIntervalMs)}`, - ) - } -} - -/** - * Register a prepended `agent/step` listener for the lifetime of `ctx`. - * @param ctx - plugin context; the listener is disposed with it. - * @param config - durable refresh scheduling configuration. - * @throws when the refresh interval is invalid. - */ -export function apply(ctx: Context, config: Config): void { - const refreshIntervalMs = config.refreshIntervalMs - validateRefreshInterval(refreshIntervalMs) - - ctx.on('agent/step', async ( - agent: Agent, - turn: number, - step: number, - signal: AbortSignal, - ): Promise => { - if (signal.aborted || step !== 1) return - const bash = ctx.get('bash') - if (bash === undefined) return - const previous = latestInjectedState(agent) - if (refreshIntervalMs !== undefined && refreshIntervalMs > 0 && previous !== undefined) { - const now = Date.now() - if (now >= previous.time && now - previous.time < refreshIntervalMs) return - } - const location = await queryTmuxLocation(bash, process.pid, signal) - if (location === undefined) return - const state = renderState(location) - if (previous !== undefined && previous.state === state) return - agent.inject(createUserMessage({ - content: [{ type: 'text', text: renderReading(location, turn) }], - source: { kind: 'plugin', plugin: name }, - })) - }, { prepend: true }) -} diff --git a/packages/context/tmux-context/src/invariant.ts b/packages/context/tmux-context/src/invariant.ts deleted file mode 100644 index 181f1a2289..0000000000 --- a/packages/context/tmux-context/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tmux-context`. - * @module @deepseek-ai/dsh-tmux-context/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-tmux-context' - -/** Cordis companion plugin name. */ -export const name = 'tmux-context-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: a reading is a per-turn snapshot of external tmux state, so the session - * holds no cross-event relation to check; scheduling and format are owned by pipeline tests. - */ -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 */ diff --git a/packages/context/tmux-context/tests/tmux-context.e2e.ts b/packages/context/tmux-context/tests/tmux-context.e2e.ts deleted file mode 100644 index 06e3c1f3f5..0000000000 --- a/packages/context/tmux-context/tests/tmux-context.e2e.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { readFile, readdir } from 'node:fs/promises' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { type SessionEvent } from '@deepseek-ai/dsh-session' -import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' - -// Keep the Loader config under examples so both modes exercise the same deployable -// topology: local fixture source plus bare plugins owned by the examples workspace. -const driver = fileURLToPath(new URL( - '../../../../examples/headless-agent/tests/fixtures/tmux-context-driver.ts', - import.meta.url, -)) -const configPath = fileURLToPath(new URL( - '../../../../examples/headless-agent/tests/fixtures/tmux-context.cordis.yml', - import.meta.url, -)) -const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) - -async function jsonlFiles(dir: string): Promise { - const entries = await readdir(dir, { withFileTypes: true }) - const paths = await Promise.all(entries.map(async (entry) => { - const path = join(dir, entry.name) - if (entry.isDirectory()) return jsonlFiles(path) - return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] - })) - return paths.flat() -} - -describe('tmux-context through a real headless cordis.yml', () => { - it('injects one ordered tmux-location event on the first turn and suppresses the unchanged second', async () => { - let events: SessionEvent[] = [] - const { stderr } = await runLoaderSmoke({ - label: 'tmux-context headless smoke', - tempDirPrefix: 'tmux-context-e2e-', - binScript: driver, - libBinScript: driver, - configPath, - tsconfigPath: repoTsconfig, - inspect: async (cwd) => { - const logs = await jsonlFiles(join(cwd, '.sessions')) - expect(logs).toHaveLength(1) - const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') - events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) - }, - }) - expect(stderr).not.toContain('UNHANDLED') - expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2) - - const contexts = events.filter( - (event): event is SessionEvent<'user/message'> => - event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === 'tmux-context') - // Two identical-state turns: the location injects once and is suppressed after. - expect(contexts).toHaveLength(1) - - const [reading] = contexts - if (reading === undefined) throw new Error('missing tmux-context reading') - const starts = events.filter(event => event.type === 'step/start') - expect(reading.seq).toBeLessThan(starts[0]!.seq) - expect(reading.surfaceOp).toBe('append') - - const text = reading.data.content - .filter(block => block.type === 'text') - .map(block => block.text) - .join('\n') - expect(text).toBe( - 'tmux location (turn 1):\n' - + 'session work, window 0 "editor", pane 1 %3\n' - + 'window active=1, pane active=1, layout a1b2,80x24,0,0,4', - ) - - const headers = events.filter(event => event.type === 'request/header') - expect(JSON.stringify(headers)).not.toContain('tmux location (turn') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) -}) diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts deleted file mode 100644 index fedd672f57..0000000000 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ /dev/null @@ -1,367 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' -import { createUserMessage } from '@deepseek-ai/dsh-llm' -import { BashExecutor } from '@deepseek-ai/dsh-bash' -import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' -import * as tmuxContext from '@deepseek-ai/dsh-tmux-context' -import type { Config } from '@deepseek-ai/dsh-tmux-context' - -const SIGNAL = new AbortController().signal - -/** One `#{...}`-joined tmux reading line for the eight queried fields. */ -function tmuxLine(fields: { - sessionName?: string - windowIndex?: string - windowName?: string - paneIndex?: string - paneId?: string - windowActive?: string - paneActive?: string - windowLayout?: string -} = {}): string { - return [ - fields.sessionName ?? '0', - fields.windowIndex ?? '1', - fields.windowName ?? 'node', - fields.paneIndex ?? '2', - fields.paneId ?? '%90', - fields.windowActive ?? '1', - fields.paneActive ?? '0', - fields.windowLayout ?? 'd517,270x71,0,0{135x71,0,0,87,134x71,136,0[134x35,136,0,90,134x35,136,36,93]}', - ].join('\\t') -} - -function runResult(stdout: string, overrides: Partial = {}): BashRunResult { - return { - exitCode: 0, - signal: null, - timedOut: false, - aborted: false, - timeoutMs: 60_000, - stdout: { text: stdout, truncated: false }, - stderr: { text: '', truncated: false }, - ...overrides, - } -} - -/** A scriptable fake `ctx.bash` recording the command it was asked to run. */ -class FakeBash extends BashExecutor { - commands: string[] = [] - result: BashRunResult = runResult(`${tmuxLine()}\n`) - runError?: Error - - override resolve(request: BashExecRequest): BashExecSpec { - return { - command: request.command, - workdir: request.workdir ?? '/work', - timeoutMs: request.timeoutMs ?? 60_000, - stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, - signal: request.signal, - sandboxPolicy: request.sandboxPolicy, - } - } - override async run(spec: BashExecSpec): Promise { - this.commands.push(spec.command) - if (this.runError) throw this.runError - return this.result - } - override start(): BashProcess { - throw new Error('tmux-context must never start a background task') - } -} - -async function mount(config: Config, withBash: true): Promise<{ ctx: Context; bash: FakeBash }> -async function mount(config?: Config, withBash?: boolean): Promise<{ ctx: Context; bash: FakeBash | undefined }> -async function mount( - config: Config = {}, - withBash = false, -): Promise<{ ctx: Context; bash: FakeBash | undefined }> { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - let bash: FakeBash | undefined - if (withBash) { - await ctx.plugin(FakeBash) - bash = ctx.bash as FakeBash - } - await ctx.plugin(tmuxContext, config) - return { ctx, bash } -} - -function sessionAgent(session: Session, id = 'agent'): Agent { - return { - id: SessionId(id), - options: {}, - session, - status: 'running', - acceptsNextStep: true, - ctx: new Context(), - followup: () => {}, - steer: () => {}, - inject(input) { - session.append('user/message', input, { surfaceOp: 'append' }) - }, - send: () => {}, - cancel() {}, - whenIdle: () => Promise.resolve(), - } -} - -function openMessageTurn(session: Session, turn: number): void { - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: `turn ${turn}` }], - source: { kind: 'user' }, - }), { surfaceOp: 'append' }) -} - -function contextTexts(session: Session): string[] { - const texts: string[] = [] - for (const event of session.events) { - if (event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === 'tmux-context') { - texts.push(event.data.content.find(block => block.type === 'text')?.text ?? '') - } - } - return texts -} - -async function fire( - ctx: Context, - agent: Agent, - turn: number, - step: number, - signal: AbortSignal = SIGNAL, -): Promise { - await agentEvents(ctx, agent).serial('agent/step', turn, step, signal) -} - -afterEach(() => { - vi.restoreAllMocks() - vi.useRealTimers() -}) - -describe('tmux-context injection', () => { - it('injects the tmux location on the first step of a turn', async () => { - const { ctx } = await mount({}, true) - const session = new Session(SessionId('first')) - openMessageTurn(session, 1) - - await fire(ctx, sessionAgent(session), 1, 1) - - expect(contextTexts(session)).toEqual([ - 'tmux location (turn 1):\n' - + 'session 0, window 1 "node", pane 2 %90\n' - + 'window active=1, pane active=0, ' - + 'layout d517,270x71,0,0{135x71,0,0,87,134x71,136,0[134x35,136,0,90,134x35,136,36,93]}', - ]) - const event = session.events.at(-1) - if (event?.type !== 'user/message') throw new Error('missing tmux context') - expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'tmux-context' }) - expect(event.surfaceOp).toBe('append') - }) - - it('queries the pane this process runs in and matches its controlling tty', async () => { - const { ctx, bash } = await mount({}, true) - const session = new Session(SessionId('command')) - openMessageTurn(session, 1) - - await fire(ctx, sessionAgent(session), 1, 1) - - expect(bash.commands).toHaveLength(1) - const command = bash.commands[0]! - expect(command).toContain('[ -n "$TMUX_PANE" ]') - expect(command).toContain('tmux display-message -t "$TMUX_PANE" -p') - // Guards against an inherited $TMUX_PANE: the pane's tty must equal this - // process's controlling tty (resolved for this exact pid). - expect(command).toContain(`ps -o tty= -p ${process.pid}`) - // The exact fragment matters: unquoted, `#` starts a shell comment and the - // substitution silently breaks while a substring check still passes. - expect(command).toContain('pane_tty=$(tmux display-message -t "$TMUX_PANE" -p \'#{pane_tty}\') || exit 1') - expect(command).toContain('[ "$pane_tty" = "/dev/$self_tty" ]') - }) - - it('does not run on later steps of a turn', async () => { - const { ctx, bash } = await mount({}, true) - const session = new Session(SessionId('later-step')) - openMessageTurn(session, 1) - - await fire(ctx, sessionAgent(session), 1, 2) - - expect(bash.commands).toHaveLength(0) - expect(contextTexts(session)).toHaveLength(0) - }) - - it('re-injects a new turn only when tmux state changed', async () => { - const { ctx, bash } = await mount({}, true) - const session = new Session(SessionId('change')) - const agent = sessionAgent(session) - - openMessageTurn(session, 1) - await fire(ctx, agent, 1, 1) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - - // Same state on turn 2: suppressed. - openMessageTurn(session, 2) - await fire(ctx, agent, 2, 1) - session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - expect(contextTexts(session)).toHaveLength(1) - - // Moved pane on turn 3: re-injected. - bash.result = runResult(`${tmuxLine({ windowName: 'shell', paneId: '%12' })}\n`) - openMessageTurn(session, 3) - await fire(ctx, agent, 3, 1) - - const texts = contextTexts(session) - expect(texts).toHaveLength(2) - expect(texts[1]).toContain('tmux location (turn 3):') - expect(texts[1]).toContain('window 1 "shell", pane 2 %12') - }) - - it('honors a positive refresh interval between injections', async () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - const { ctx, bash } = await mount({ refreshIntervalMs: 10_000 }, true) - const session = new Session(SessionId('interval')) - const agent = sessionAgent(session) - - openMessageTurn(session, 1) - await fire(ctx, agent, 1, 1) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - - // Changed state but inside the interval: suppressed, and never queried. - bash.result = runResult(`${tmuxLine({ paneId: '%99' })}\n`) - vi.setSystemTime(5_000) - openMessageTurn(session, 2) - await fire(ctx, agent, 2, 1) - expect(contextTexts(session)).toHaveLength(1) - expect(bash.commands).toHaveLength(1) - - // Past the interval: queried and re-injected. - vi.setSystemTime(12_000) - openMessageTurn(session, 3) - await fire(ctx, agent, 3, 1) - expect(contextTexts(session)).toHaveLength(2) - expect(bash.commands).toHaveLength(2) - }) -}) - -describe('tmux-context prior-reading resilience', () => { - it('treats a prior non-text plugin reading as absent and injects afresh', async () => { - const { ctx, bash } = await mount({}, true) - const session = new Session(SessionId('prior-non-text')) - const agent = sessionAgent(session) - openMessageTurn(session, 1) - session.append('user/message', createUserMessage({ - content: [{ type: 'reasoning', text: 'not a location' }], - source: { kind: 'plugin', plugin: 'tmux-context' }, - }), { surfaceOp: 'append' }) - - await fire(ctx, agent, 1, 1) - - expect(bash.commands).toHaveLength(1) - expect(contextTexts(session).at(-1)).toContain('tmux location (turn 1):') - }) - - it('treats a prior single-line plugin reading (no newline) as empty state', async () => { - const { ctx, bash } = await mount({}, true) - const session = new Session(SessionId('prior-single-line')) - const agent = sessionAgent(session) - openMessageTurn(session, 1) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'single line, no newline' }], - source: { kind: 'plugin', plugin: 'tmux-context' }, - }), { surfaceOp: 'append' }) - - await fire(ctx, agent, 1, 1) - - // Empty prior state never equals the multi-line reading, so it re-injects. - expect(bash.commands).toHaveLength(1) - expect(contextTexts(session).at(-1)).toContain('tmux location (turn 1):') - }) -}) - -describe('tmux-context no-op paths', () => { - it('is a no-op when no bash executor is mounted', async () => { - const { ctx } = await mount() - const session = new Session(SessionId('no-bash')) - openMessageTurn(session, 1) - - await fire(ctx, sessionAgent(session), 1, 1) - - expect(contextTexts(session)).toHaveLength(0) - }) - - it('is a no-op when the tmux query exits nonzero (outside tmux, or an inherited env whose tty does not match the pane)', async () => { - const { ctx, bash } = await mount({}, true) - bash.result = runResult('', { exitCode: 1 }) - const session = new Session(SessionId('outside-tmux')) - openMessageTurn(session, 1) - - await fire(ctx, sessionAgent(session), 1, 1) - - expect(contextTexts(session)).toHaveLength(0) - }) - - it('is a no-op when the reading has the wrong field count', async () => { - const { ctx, bash } = await mount({}, true) - bash.result = runResult('0\\t1\\tnode\n') - const session = new Session(SessionId('malformed')) - openMessageTurn(session, 1) - - await fire(ctx, sessionAgent(session), 1, 1) - - expect(contextTexts(session)).toHaveLength(0) - }) - - it('is a no-op when the pane id is empty', async () => { - const { ctx, bash } = await mount({}, true) - bash.result = runResult(`${tmuxLine({ paneId: '' })}\n`) - const session = new Session(SessionId('empty-pane')) - openMessageTurn(session, 1) - - await fire(ctx, sessionAgent(session), 1, 1) - - expect(contextTexts(session)).toHaveLength(0) - }) - - it('skips an already-aborted step and runs before ordinary agent/step listeners', async () => { - const { ctx } = await mount({}, true) - const session = new Session(SessionId('ordering')) - const agent = sessionAgent(session) - openMessageTurn(session, 1) - let ordinarySawContext = false - ctx.on('agent/step', (subject) => { - ordinarySawContext = subject.session.events.some( - event => event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === 'tmux-context', - ) - }) - - const abort = new AbortController() - abort.abort() - await fire(ctx, agent, 1, 1, abort.signal) - expect(contextTexts(session)).toHaveLength(0) - - await fire(ctx, agent, 1, 1) - expect(ordinarySawContext).toBe(true) - expect(contextTexts(session)).toHaveLength(1) - }) -}) - -describe('tmux-context configuration', () => { - it('rejects a negative refresh interval at plugin load', async () => { - await expect(mount({ refreshIntervalMs: -1 })).rejects.toThrow( - /refreshIntervalMs must be a non-negative safe integer/, - ) - }) - - it('rejects a non-integer refresh interval at plugin load', async () => { - await expect(mount({ refreshIntervalMs: 1.5 })).rejects.toThrow( - /refreshIntervalMs must be a non-negative safe integer/, - ) - }) -}) diff --git a/packages/context/tmux-context/tsconfig.json b/packages/context/tmux-context/tsconfig.json deleted file mode 100644 index fe893f9402..0000000000 --- a/packages/context/tmux-context/tsconfig.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": ["src"], - "references": [ - { - "path": "../../../vendor/cosmokit" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../../vendor/schemastery" - }, - { - "path": "../../llm/llm" - }, - { - "path": "../../bash/bash" - }, - { - "path": "../../core/agent" - }, - { - "path": "../../core/system-prompt" - }, - { - "path": "../../support/loader-smoke" - }, - { - "path": "../../support/invariants" - }, - { - "path": "../../core/session" - } - ] -} diff --git a/packages/guard/README.i18n.yaml b/packages/guard/README.i18n.yaml index c323a9b295..0f6859f941 100644 --- a/packages/guard/README.i18n.yaml +++ b/packages/guard/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/guard/README.md -README.md: b7375fd2bb12ae0cec94b13e6a1012c6f143bdad -README.zh.md: bba5c144d0663266e4327e388b9915cde176ce08 +README.md: 59ab2fcea91bbbb9f6628523f3c6f13497d6f742 +README.zh.md: caec5618f9ddc27825ad68cd4145f6197c7b0517 diff --git a/packages/guard/README.md b/packages/guard/README.md index a164bdcddb..59ab2fcea9 100644 --- a/packages/guard/README.md +++ b/packages/guard/README.md @@ -7,6 +7,5 @@ Behavioral guard plugins that watch the agent loop and correct it — some by nu | Package | Role | ctx key | |---|---|---| | `repeat-tool-guard/` | Advisory reminders when an agent loops on identical tool calls | (listens on `ctx.tools`' waterfalls) | -| `source-guard/` | Denies file edits inside a dsh staging worktree until the required skill is loaded | (listens on `ctx.tools`' waterfalls) | An advisory guard's reminders travel as `additionalContexts` on the `tools/post-execute` decision; the agent loop appends them as logged plugin-sourced `user/message` events after the step's tool results (see [the tools package](../core/tools)), so everything such a guard says to the model is reconstructable from the session log. An enforcing guard instead decides on `tools/pre-execute`, where a `deny` becomes the call's error result and the operation never dispatches. diff --git a/packages/guard/README.zh.md b/packages/guard/README.zh.md index bba5c144d0..caec5618f9 100644 --- a/packages/guard/README.zh.md +++ b/packages/guard/README.zh.md @@ -2,10 +2,10 @@ [English](README.md) | 中文 -这组行为 guard 插件会监视 agent loop(智能体循环)中的低效模式,并提醒模型调整方向。这里只有一个**产品**包(package),不设接口/实现 seam:guard 是现有核心 seam(`tools/post-execute`、`agent/prompt-submit`、`agent/status`)的自包含消费方,并非可替换能力。 +这组行为 guard 插件会监视 agent(智能体)循环并加以纠正:一部分提醒模型调整方向,另一部分则直接拒绝某个操作。它们都是**产品**包,不设接口/实现 seam:guard 是现有核心 seam(`tools/pre-execute`、`tools/post-execute`、`agent/prompt-submit`、`agent/status`)的自包含消费方,并非可替换能力。 | 包 | 职责 | ctx 键 | |---|---|---| -| `repeat-tool-guard/` | 当 agent 对完全相同的工具调用反复循环时给出提示 | (监听 `ctx.tools` 的 waterfall,即瀑布式事件) | +| `repeat-tool-guard/` | 当 agent 对完全相同的工具调用反复循环时给出提示 | (监听 `ctx.tools` 的 waterfall(瀑布式事件)) | -提示以 `additionalContexts` 形式附在 `tools/post-execute` 决策中传递;agent loop 会在该步骤的工具结果之后,将其追加为有日志记录、来源为插件的 `user/message` 事件(参见[工具包](../core/tools))。因此,guard 告诉模型的所有内容都能从会话日志中重建。 +建议型 guard 的提示以 `additionalContexts` 形式附在 `tools/post-execute` 决策中传递;agent loop 会在该步骤的工具结果之后,将其追加为有日志记录、来源为插件的 `user/message` 事件(参见[工具包](../core/tools))。因此,此类 guard 告诉模型的所有内容都能从会话日志中重建。强制型 guard 则在 `tools/pre-execute` 上做出决策,其 `deny` 会成为该调用的错误结果,操作绝不会分派执行。 diff --git a/packages/guard/source-guard/README.i18n.yaml b/packages/guard/source-guard/README.i18n.yaml deleted file mode 100644 index 79f2f9f423..0000000000 --- a/packages/guard/source-guard/README.i18n.yaml +++ /dev/null @@ -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/guard/source-guard/README.md -README.md: a7406d71c6591f78d12b6c02ec22bc4b0b3d517f -README.zh.md: 916d0513b77e17989730fdf27ef50d21013f4e58 diff --git a/packages/guard/source-guard/README.md b/packages/guard/source-guard/README.md deleted file mode 100644 index a7406d71c6..0000000000 --- a/packages/guard/source-guard/README.md +++ /dev/null @@ -1,88 +0,0 @@ -# @deepseek-ai/dsh-source-guard - -English | [中文](README.zh.md) - -An enforcement gate, not a model-facing tool: it never appears in the tool list and adds exactly one behavior — it denies a `write` or `edit` whose target sits inside the dsh checkout the running harness was launched from, on that checkout's own branch, until the calling session's durable log shows a successful load of the `dsh-customize` skill. That skill requires personal changes to be implemented in a task worktree and integrated under the staging lock; this plugin turns its central rule ("do not edit the personal staging checkout directly") from prompt guidance into a boundary the model cannot cross by forgetting. - -## Config - -```yaml -- id: source-guard - name: '@deepseek-ai/dsh-source-guard' - config: - requiredSkill: dsh-customize # default; the skill whose load lifts the denial - tools: [write, edit] # default; the gated tool names - protectedCheckout: /path/to/checkout # defaults to this module's own location -``` - -Every field fails loud at plugin load: an empty `tools` list, a blank `requiredSkill`, or a relative `protectedCheckout` throws, never a silent fall-back. - -`protectedCheckout` names a path inside the checkout to guard, and its worktree supplies BOTH protected identities: the repository and the exact branch. Its default is this module's own file, which resolves the checkout the running harness was launched from — the live deployment, whatever its branch is named. Nothing about the branch is configured or pattern-matched, so a maintainer whose staging branch follows no naming convention is protected identically. A harness running from an installed copy resolves a different repository, or none, and therefore guards nothing; the rule is meaningless outside a source checkout. - -The shipped TUI composition (`apps/cli/base.cordis.yml`) loads this plugin with defaults. It is inert for anyone whose workspace is not the launcher's own checkout, so an ordinary project sees no change. - -## Which paths are protected - -Protection is decided by git identity read from files — `.git`, its `gitdir:` pointer, and `HEAD` — never by path prefix and never by running `git`. Prefix matching would be wrong here: the task worktrees the skill prescribes live *inside* the staging tree, at `/.worktrees/...`, and are exactly where edits belong. - -Resolution walks OUTWARD from the target and stops at the first enclosing worktree, so it reports the INNERMOST one. Denial needs that worktree to match the launcher's on BOTH identities: the same shared git directory and the same branch. A task worktree nested under the protected tree answers with its own task branch and passes; the launcher's own tree answers with the launcher's branch and is denied. Repository identity is compared on symlink-resolved paths, so two routes to one repository — a session cwd under `/var/...` and a configured path under `/private/var/...` on macOS — match rather than falling open. - -Requiring the exact branch, not a name pattern, keeps the gate on the live deployment only. A stale sibling checkout left by an earlier install shares the repository but runs no launcher, so the workflow rule does not apply to it and it stays editable. - -A `gitdir:` pointer may be absolute (what `git worktree add` writes) or relative, which git resolves against the worktree directory holding it; both resolve here. A relative `file_path` resolves against the calling session's workspace, exactly as the filesystem tools resolve it, so it is not an unguarded route to a protected file. - -The gate is deliberately narrow: - -- **`read` is never gated.** Inspecting the staging checkout violates nothing, so only mutating tools are candidates. -- **`bash` is not gated.** Reliably classifying mutating shell commands is out of scope, so a determined model can still change staging through a shell. -- **Calls without an agent are allowed.** A direct `ctx.tools.execute()` caller has no session to replay and no model to correct. -- **Unresolvable git state fails OPEN.** A path outside any worktree, a detached HEAD on either side, a different repository or branch, a malformed `.git` pointer, or unreadable metadata all leave the call to the rest of the chain. A gate that blocked every write whenever git identity was unavailable would cause more harm than the violation it prevents. -- **An unresolvable target is not judged.** An empty `file_path`, a non-string one, or a relative one in a session that names no workspace leaves the call to the tool's own validation. - -Worktree identity is cached per target directory for the plugin's lifetime, so repeated writes in one directory read git metadata once; a mid-session branch switch is therefore not observed. - -## How the denial lifts - -Satisfaction is replayed from the session's durable log: a `tool/call` naming the `skill` tool whose arguments parse to `{name: }`, paired by call id with a non-error `tool/result`. Because the log is the only state, satisfaction survives a session resume — a resumed session that already loaded the skill is not asked again. A failed load, a differently-named skill, and malformed argument JSON all leave the denial in place. - -Satisfaction is per session, so a subagent with its own session must load the skill itself. - -## Enforcement point - -The gate is a `tools/pre-execute` listener returning `{kind: 'deny', reason}`, so the call never dispatches and the file is never touched. It delegates via `next()` in every non-violating case. Denial — not an advisory reminder — is the point: an advisory nudge leaves the violation committed, and `ask` degrades to denial in a composition without approval support. - -## Testing - -Unit suites drive a real agent loop against a mock adapter over real git-metadata fixtures — a staging worktree, a task worktree nested inside it, a plain clone, a foreign repository on a staging-named branch, a detached HEAD, absolute and relative `gitdir:` pointers, a symlinked route to the same repository, and unreadable metadata — to per-file 100%. The assembled-run evidence is the Loader-composition smoke (`tests/loader-composition.e2e.ts`): it boots a real headless app over `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml`, seeds a staging worktree in a temporary cwd, and asserts the tool result is an error carrying the exact denial while the targeted file keeps its original bytes. - -## Model Experience - -### Denied filesystem call - -#### What the model sees - -A gated call into a protected worktree without the required skill loaded returns an error result carrying exactly the text below. No prompt section, tool schema, or successful-call text is added, and an allowed call is indistinguishable from one made without this plugin. - -##### Denial result - -```markdown -Error: Editing "" directly is not allowed: it is inside the dsh checkout this session is running from, on branch . Load the skill first and follow it — implement in a task worktree, then integrate under the staging lock. -``` - -#### Token effect - -Zero tokens while no denial occurs. A denial adds its small retained error result and avoids the success payload the call would have produced. - -#### KV Cache effect - -Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. - -## Known Limitations and Deferred Work - -- **`bash` is ungated** — the guard is a boundary for the filesystem tools only; a shell command can still mutate a protected worktree. -- **Worktree identity is cached per directory for the plugin's lifetime** — switching a protected worktree's branch mid-session does not change decisions until the next load, on either the target or the launcher side. -- **Only the launcher's own checkout is protected** — a stale sibling checkout of the same repository stays editable, deliberately; run `dsh` from it to protect it. -- **Disarmed outside a source checkout** — a harness running from an installed copy protects nothing unless `protectedCheckout` names a real checkout explicitly. -- **Satisfaction is per session** — a subagent's session must load the skill itself; a parent's load does not carry over. -- **Fail-open on unresolvable git state** — a broken or unreadable `.git` means no protection, chosen deliberately over blocking every edit. -- **One skill lifts the whole gate for the session** — loading it does not verify the workflow was actually followed, only that the instructions were read. diff --git a/packages/guard/source-guard/README.zh.md b/packages/guard/source-guard/README.zh.md deleted file mode 100644 index 916d0513b7..0000000000 --- a/packages/guard/source-guard/README.zh.md +++ /dev/null @@ -1,88 +0,0 @@ -# @deepseek-ai/dsh-source-guard - -[English](README.md) | 中文 - -这是一道强制执行门禁,而非面向模型的工具:它不会出现在工具列表中,只增加一种行为。若 `write` 或 `edit` 的目标位于运行中 harness 启动来源的 dsh 检出目录内,并处于该检出目录自身的分支上,它会拒绝调用,直到调用方会话的持久日志表明已成功加载 `dsh-customize` skill(技能)。该 skill 要求在任务 worktree 中实现个人变更,并在 staging 锁保护下完成集成;本插件把其核心规则(「不要直接编辑个人 staging 检出目录」)从提示词指导变成一道模型无法因遗忘而越过的边界。 - -## 配置 - -```yaml -- id: source-guard - name: '@deepseek-ai/dsh-source-guard' - config: - requiredSkill: dsh-customize # default; the skill whose load lifts the denial - tools: [write, edit] # default; the gated tool names - protectedCheckout: /path/to/checkout # defaults to this module's own location -``` - -插件加载时,每个字段都会对错误配置快速失败:`tools` 为空列表、`requiredSkill` 为空白字符串,或 `protectedCheckout` 使用相对路径时,都会抛出错误,绝不静默回退。 - -`protectedCheckout` 指定位于待保护检出目录内的一条路径;其 worktree 会提供两项受保护身份:仓库和确切分支。其默认值是本模块自己的文件,由此解析出运行中 harness 启动来源的检出目录——当前运行的部署,无论其分支采用什么名称。分支既无需配置,也不会通过模式匹配,因此 staging 分支不遵循任何命名约定的维护者同样会受到保护。若 harness 从已安装副本运行,则会解析到另一个仓库,或根本解析不到仓库,因此不会保护任何内容;这条规则在源码检出目录之外没有意义。 - -已交付的 TUI 组合(`apps/cli/base.cordis.yml`)会以默认配置加载本插件。若用户的工作区并非启动器自身所在的检出目录,本插件不会生效,因此普通项目不会发生任何变化。 - -## 受保护的路径 - -保护范围根据从文件读取的 Git 身份确定,即 `.git`、其中的 `gitdir:` 指针和 `HEAD`;既不按路径前缀判断,也不运行 `git`。此处若匹配路径前缀就会出错:skill 要求使用的任务 worktree 位于 staging 树*内部*的 `/.worktrees/...`,而这正是应该进行编辑的位置。 - -解析过程从目标路径开始向外逐层查找,遇到第一个所属 worktree 就停止,因此返回最内层的 worktree。只有该 worktree 在两项身份上都与启动器的 worktree 匹配,才会拒绝:共用同一个共享 Git 目录,且分支相同。嵌套在受保护树下的任务 worktree 会返回自己的任务分支并获准;启动器自身所在的树会返回启动器的分支并被拒绝。仓库身份会按解析符号链接后的路径进行比较,因此指向同一仓库的两条路径——macOS 上位于 `/var/...` 下的会话 cwd 和位于 `/private/var/...` 下的配置路径——会相互匹配,而不会触发故障放行(fail-open)。 - -要求匹配确切分支而非名称模式,可确保门禁仅作用于当前运行的部署。先前安装留下的陈旧同级检出目录虽然共享仓库,却没有运行启动器,因此该工作流规则不适用于它,它仍可编辑。 - -`gitdir:` 指针既可以是绝对路径(`git worktree add` 写入的形式),也可以是相对路径;Git 会以包含该指针的 worktree 目录为基准解析相对路径,本插件对两者都能解析。相对 `file_path` 会像文件系统工具一样,相对于调用会话的工作区解析,因此不会成为绕过门禁访问受保护文件的路径。 - -门禁刻意保持较窄的范围: - -- **`read` 从不受门禁限制。** 检查 staging 检出不构成违规,因此只有修改类工具是候选项。 -- **`bash` 不受门禁限制。** 可靠识别会修改内容的 shell 命令不在范围内,因此执意修改的模型仍可通过 shell 修改 staging。 -- **没有 agent(智能体)的调用会被放行。** 直接调用 `ctx.tools.execute()` 的调用方没有可供回放的会话,也没有需要纠正的模型。 -- **无法解析 Git 状态时故障放行。** 不属于任何 worktree 的路径、任一侧的 HEAD 分离状态、其他仓库或分支、格式错误的 `.git` 指针或不可读的元数据,都会把调用交给链中后续环节处理。若每逢 Git 身份不可用就阻止所有写入,这道门禁造成的危害将大于它所防止的违规。 -- **无法解析的目标不会被判断。** `file_path` 为空、不是字符串,或它是相对路径而会话未指定工作区时,调用会交给工具自身校验。 - -插件会在其整个生命周期内按目标目录缓存 worktree 身份,因此同一目录中的重复写入只读取一次 Git 元数据;由此,系统不会观察到会话中途的分支切换。 - -## 如何解除拒绝 - -是否满足解锁条件由会话的持久日志回放得出:日志中存在一条 `tool/call`,它调用名为 `skill` 的工具,参数可解析为 `{name: }`,并且有一条调用 id 相同的非错误 `tool/result` 与之配对。由于日志是唯一状态源,恢复会话时仍能保留这一结果:若恢复的会话已经加载该 skill,系统不会再次要求加载。加载失败、skill 名称不同或参数 JSON 格式错误,都会让拒绝继续生效。 - -解锁条件按会话独立满足,因此拥有独立会话的 subagent 必须自行加载该 skill。 - -## 强制执行点 - -门禁是一个 `tools/pre-execute` 监听器,返回 `{kind: 'deny', reason}`,因此调用绝不会分派执行,文件也绝不会被修改。在所有不违规的情况下,它都会通过 `next()` 委派。这里刻意采用拒绝而非建议性提醒:建议性提醒仍会让违规落地,而在不支持批准的组合中,`ask` 会退化为拒绝。 - -## 测试 - -单元测试套件基于真实 Git 元数据 fixture(测试前置数据),使用 mock 适配器驱动真实 agent loop(智能体循环):覆盖 staging worktree、嵌套其中的任务 worktree、普通克隆、位于 staging 命名分支上的其他仓库、HEAD 分离状态、绝对和相对 `gitdir:` 指针、指向同一仓库的符号链接路径以及不可读元数据,达到逐文件 100% 覆盖率。组装运行层面的证据来自 Loader 组合冒烟测试(`tests/loader-composition.e2e.ts`):它通过 `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml` 启动一个真实的 headless 应用,在临时 cwd 中植入 staging worktree,并断言工具结果是携带精确拒绝文本的错误,同时目标文件保持原始字节不变。 - -## 模型体验 - -### 被拒绝的文件系统调用 - -#### 模型看到的内容 - -如果未加载必需 skill 就对受保护 worktree 发起受门禁限制的调用,系统会返回错误结果,其中的文本与下文完全一致。系统不会添加提示词段、工具 schema 或成功调用文本;允许的调用与未启用此插件时的调用完全无法区分。 - -##### 拒绝结果 - -```markdown -Error: Editing "" directly is not allowed: it is inside the dsh checkout this session is running from, on branch . Load the skill first and follow it — implement in a task worktree, then integrate under the staging lock. -``` - -#### Token 影响 - -未发生拒绝时为零 token。一次拒绝会添加一条会保留在历史中的短小错误结果,同时避免生成该调用原本会产生的成功载荷。 - -#### KV Cache 影响 - -仅追加;新出现的内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 - -## 已知限制与暂缓工作 - -- **`bash` 不受门禁限制**:此插件只为文件系统工具提供边界;shell 命令仍可修改受保护的 worktree。 -- **插件生命周期内按目录缓存 worktree 身份**:在会话中途切换受保护 worktree 的分支,不会改变判断结果,直至下次加载插件;目标侧和启动器侧都是如此。 -- **仅保护启动器自身的检出目录**:同一仓库中的陈旧同级检出目录会被刻意保留为可编辑状态;若要保护它,请从中运行 `dsh`。 -- **源码检出之外不启用**:从已安装副本运行的 harness 不保护任何内容,除非 `protectedCheckout` 明确指定真实检出目录。 -- **解锁条件按会话独立满足**:subagent 的会话必须自行加载该 skill;父会话的加载状态不会继承。 -- **无法解析 Git 状态时故障放行**:损坏或不可读的 `.git` 会使保护失效;这是刻意选择的结果,因为另一方案是阻止所有编辑。 -- **仅加载一个 skill 即可为会话解除整道门禁**:加载该 skill 并不能验证是否实际遵循工作流,只能证明已阅读这些指令。 diff --git a/packages/guard/source-guard/package.json b/packages/guard/source-guard/package.json deleted file mode 100644 index 4e893e0a69..0000000000 --- a/packages/guard/source-guard/package.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-source-guard", - "description": "Source-guard plugin: denies direct file edits inside a dsh staging worktree until the required customization skill is loaded", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" - ], - "license": "BSD-3-Clause", - "dependencies": { - "schemastery": "^3.18.0" - }, - "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-fs": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-sandbox": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "devDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-fs-local": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-loader-smoke": "workspace:^", - "@deepseek-ai/dsh-sandbox": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" - } -} diff --git a/packages/guard/source-guard/src/index.ts b/packages/guard/source-guard/src/index.ts deleted file mode 100644 index 4e242efbd0..0000000000 --- a/packages/guard/source-guard/src/index.ts +++ /dev/null @@ -1,319 +0,0 @@ -/** - * Denies model-driven file mutation inside a dsh staging worktree until the - * calling session has loaded the required customization skill. Config, git - * resolution, and satisfaction semantics live in the package README; rationale - * lives in the source-guard Agent Note. - * @module @deepseek-ai/dsh-source-guard - */ - -import { dirname, isAbsolute, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import type { Context } from 'cordis' -import z from 'schemastery' -import { canonicalPath } from '@deepseek-ai/dsh-sandbox' -import type {} from '@deepseek-ai/dsh-fs' -import type { CallId } from '@deepseek-ai/dsh-llm' -import type { Session } from '@deepseek-ai/dsh-session' -import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools' - -export const name = 'source-guard' - -/** The `ctx.fs` provider supplies the git-metadata reads this guard resolves paths with. */ -export const inject = ['fs'] - -/** - * Plugin config, validated by the same-named schemastery schema plus the - * load-time checks in `apply` (misconfiguration fails loud: an empty `tools` - * list, a blank `requiredSkill`, or a relative `protectedCheckout` throws at - * plugin load, never a silent fall-back). - */ -export interface Config { - /** Skill whose loaded presence in the session lifts the denial (default `dsh-customize`). */ - requiredSkill?: string - /** Tool names to gate (default `['write', 'edit']`). */ - tools?: string[] - /** - * Absolute path inside the checkout this guard protects. Its worktree - * supplies BOTH protected identities: the repository (targets in any other - * repository are ignored) and the exact branch (only that branch's worktree - * is protected). Defaults to this module's own location, which resolves the - * checkout the running harness was launched from — the live deployment, - * whatever its branch is named. Set it explicitly to guard a different - * checkout, or when the harness runs from an installed copy whose own - * location is not a checkout at all. - */ - protectedCheckout?: string -} - -export const Config: z = z.object({ - requiredSkill: z.string().default('dsh-customize'), - tools: z.array(z.string()).default(['write', 'edit']), - protectedCheckout: z.string().default(fileURLToPath(import.meta.url)), -}) - -/** - * The tool whose successful call satisfies the guard. Fixed, not configurable: - * this is the harness's own skill-loading tool name, so a deployment that - * renamed it has no skill to load and nothing for this guard to observe. - */ -const SKILL_TOOL = 'skill' - -/** - * The argument key every gated tool names its target with. `write` and `edit` - * share it (`dsh-tool-fs`), and gating a tool that does not is a - * misconfiguration the guard reports rather than silently allowing. - */ -const PATH_ARGUMENT = 'file_path' - -/** - * The absolute `file_path` a gated call targets, or `undefined` when the - * arguments carry no usable one. Arguments arrive as the loop's parsed model - * JSON, so this is a model-input boundary: any shape is possible. - * - * A relative path resolves against the calling session's workspace, exactly as - * the filesystem tools resolve it (`dsh-tool-fs`'s `sessionCwd`). Judging only - * absolute paths would leave `write` with a relative `file_path` as an - * unguarded path to the same file. - */ -function targetPath(argumentsValue: unknown, sessionCwd: string | undefined): string | undefined { - if (typeof argumentsValue !== 'object' || argumentsValue === null) return undefined - const value = (argumentsValue as Record)[PATH_ARGUMENT] - if (typeof value !== 'string' || value.length === 0) return undefined - if (isAbsolute(value)) return resolve(value) - // Without a session cwd the tools fall back to a provider-owned default this - // guard cannot observe, so the target is genuinely unresolvable here. - return sessionCwd === undefined ? undefined : resolve(sessionCwd, value) -} - -/** One resolved worktree's identity: the branch its HEAD names, and the repository it belongs to. */ -interface Worktree { - /** Branch name from `HEAD`, or `undefined` for a detached HEAD. */ - branch: string | undefined - /** - * Symlink-resolved absolute path of the shared git directory, identifying the - * repository across worktrees. Canonical because two paths reaching one - * repository by different symlink routes must compare equal — on macOS a - * session cwd under `/var/...` and a configured path under `/private/var/...` - * name the same directory, and a lexical comparison would fail open. - */ - commonDir: string -} - -/** - * What one git-metadata path holds: a file's text, the fact that it is a - * directory, or nothing resolvable. Every caller treats the unresolvable case - * as "not a worktree" and lets the call proceed, so distinguishing absence - * from a permission error would change no decision. - */ -type GitEntry = - | { kind: 'file'; text: string } - | { kind: 'directory' } - | { kind: 'absent' } - -/** Probe one git-metadata path, reading its text when it is a regular file. */ -async function readGitEntry(ctx: Context, path: string): Promise { - try { - const target = await ctx.fs.resolve(path) - const info = await ctx.fs.stat(target) - if (info?.type === 'directory') return { kind: 'directory' } - if (info?.type !== 'file') return { kind: 'absent' } - return { kind: 'file', text: await ctx.fs.readText(target) } - } catch { - // Any resolve/stat/read failure (absent, denied, unreadable encoding) - // yields no git identity. Nothing else can reach here: the guard performs - // no other IO. - return { kind: 'absent' } - } -} - -/** - * Branch name from a `HEAD` file's contents. A symbolic ref names a branch; a - * detached HEAD holds a raw object id and has no branch, which no staging - * pattern can match. - */ -function branchFromHead(head: string): string | undefined { - const trimmed = head.trim() - const ref = 'ref: refs/heads/' - return trimmed.startsWith(ref) ? trimmed.slice(ref.length) : undefined -} - -/** - * Resolve the git directory a worktree root's `.git` entry designates, plus - * the shared common directory. A plain clone's `.git` is a directory that is - * its own common dir; a linked worktree's `.git` is a file pointing into the - * main repository's `worktrees/`, whose common dir is two levels up. - * A `gitdir:` pointer may be relative, which git resolves against the worktree - * directory holding it. - */ -async function resolveGitDir(ctx: Context, root: string): Promise<{ gitDir: string; commonDir: string } | undefined> { - const dotGit = resolve(root, '.git') - const entry = await readGitEntry(ctx, dotGit) - // A plain clone keeps a `.git` DIRECTORY, which is both the git dir and the - // common dir; a linked worktree keeps a `.git` FILE pointing elsewhere. - if (entry.kind === 'directory') return { gitDir: dotGit, commonDir: canonicalPath(dotGit) } - if (entry.kind === 'absent') return undefined - const prefix = 'gitdir:' - const trimmed = entry.text.trim() - if (!trimmed.startsWith(prefix)) return undefined - const pointer = trimmed.slice(prefix.length).trim() - if (pointer.length === 0) return undefined - const gitDir = resolve(root, pointer) - // `/worktrees/` — the shared repository is two levels up. - return { gitDir, commonDir: canonicalPath(dirname(dirname(gitDir))) } -} - -/** - * Walk from a path toward the filesystem root and resolve the first enclosing - * worktree, or `undefined` when the path is inside none. - */ -async function findWorktree(ctx: Context, from: string): Promise { - let current = from - for (;;) { - const dirs = await resolveGitDir(ctx, current) - if (dirs !== undefined) { - const head = await readGitEntry(ctx, resolve(dirs.gitDir, 'HEAD')) - return { - branch: head.kind === 'file' ? branchFromHead(head.text) : undefined, - commonDir: dirs.commonDir, - } - } - const parent = dirname(current) - if (parent === current) return undefined - current = parent - } -} - -/** - * The skill name a `skill` call's raw argument JSON requested, or `undefined` - * when the JSON is malformed or carries no string `name`. The log stores the - * model's unparsed argument string, so this is a model-JSON boundary. - */ -function skillNameOf(rawArguments: string): string | undefined { - let parsed: unknown - try { - parsed = JSON.parse(rawArguments) - } catch { - // The model produced argument text that is not JSON; the call cannot have - // named a skill. Nothing else in this try can throw. - return undefined - } - if (typeof parsed !== 'object' || parsed === null) return undefined - const value = (parsed as Record).name - return typeof value === 'string' ? value : undefined -} - -/** - * Whether the session's durable log records a successful load of - * `requiredSkill`. Replayed from `tool/call` + `tool/result` pairs, so - * satisfaction survives a session resume: the log is the only state. - */ -function skillLoaded(session: Session, requiredSkill: string): boolean { - const requested = new Map() - for (const event of session.events) { - if (event.type === 'tool/call') { - if (event.data.name === SKILL_TOOL) requested.set(event.data.callId, event.data.arguments) - continue - } - const block = event.type === 'tool/result' ? event.data.message.content[0] : undefined - if (block === undefined || block.isError === true) continue - const rawArguments = requested.get(block.toolCallId) - if (rawArguments !== undefined && skillNameOf(rawArguments) === requiredSkill) return true - } - return false -} - -/** The denial text a blocked call reports to the model. */ -function denialReason(path: string, branch: string, requiredSkill: string): string { - return `Editing "${path}" directly is not allowed: it is inside the dsh checkout this session is running from, on branch ${branch}. ` - + `Load the ${requiredSkill} skill first and follow it — implement in a task worktree, then integrate under the staging lock.` -} - -/** - * Install the guard's listener. - * @param ctx - plugin context; the listener is scoped to it and disposed with it. - * @param config - validated {@link Config}; re-checked fail-loud here. - */ -export function apply(ctx: Context, config: Config): void { - // schemastery's .default() guarantees the fields are set after validation. - const requiredSkill = config.requiredSkill as string - const tools = config.tools as string[] - if (tools.length === 0) { - throw new Error('source-guard: `tools` must not be empty') - } - if (requiredSkill.trim().length === 0) { - throw new Error('source-guard: `requiredSkill` must not be blank') - } - const gated = new Set(tools) - - const protectedCheckout = config.protectedCheckout as string - if (!isAbsolute(protectedCheckout)) { - throw new Error(`source-guard: \`protectedCheckout\` must be an absolute path, got "${protectedCheckout}"`) - } - // Resolved once per plugin lifetime: the worktree this guard arms for, which - // supplies both the protected repository and the protected branch. A harness - // running from an installed copy resolves a different repository (or none) - // and therefore guards nothing, which is correct — the rule is meaningless - // outside a source checkout. - let protectedRepository: Promise | undefined - - /** The repository containing {@link Config.protectedCheckout}. */ - function repository(): Promise { - protectedRepository ??= findWorktree(ctx, dirname(protectedCheckout)) - return protectedRepository - } - - // Worktree identity per directory, cached for the plugin's lifetime: a - // directory's repository and branch are stable in practice, and re-reading - // git metadata on every write would repeat identical IO. A mid-session - // branch switch is therefore not observed (see the README). - const worktrees = new Map>() - - /** Resolve (and memoize) the worktree enclosing a target path's directory. */ - function worktreeOf(path: string): Promise { - const directory = dirname(path) - let pending = worktrees.get(directory) - if (pending === undefined) { - pending = findWorktree(ctx, directory) - worktrees.set(directory, pending) - } - return pending - } - - /** - * The target path and the staging branch protecting it, or `undefined` when - * the call may proceed. Fails open on every unresolvable case: a path outside - * any worktree, a detached HEAD, a different repository, or unreadable git - * metadata leaves the call to the rest of the chain, because a guard that - * blocked writes whenever git identity was unavailable would be worse than - * the violation it prevents. - */ - async function protectedTarget(exec: ToolExecution, session: Session): Promise<{ path: string; branch: string } | undefined> { - if (!gated.has(exec.name)) return undefined - const path = targetPath(exec.arguments, session.header.cwd) - if (path === undefined) return undefined - const launcher = await repository() - // A detached launcher checkout names no branch to protect, so nothing is. - if (launcher?.branch === undefined) return undefined - // Resolution walks OUTWARD from the target, so it reports the INNERMOST - // enclosing worktree: a task worktree nested under the protected tree - // answers with its own task branch, which is not the launcher's. That is - // what keeps the prescribed workflow unblocked. - const worktree = await worktreeOf(path) - if (worktree === undefined || worktree.commonDir !== launcher.commonDir) return undefined - // Only the branch the launcher itself runs from is protected: a stale - // sibling checkout of the same repository is not the live deployment. - if (worktree.branch !== launcher.branch) return undefined - return { path, branch: launcher.branch } - } - - ctx.on('tools/pre-execute', async (exec, next): Promise => { - // A direct `ctx.tools.execute()` caller has no session to replay and no - // model to correct; only agent-loop calls are gated. - if (exec.agent === undefined) return next() - const { session } = exec.agent - const target = await protectedTarget(exec, session) - if (target === undefined) return next() - if (skillLoaded(session, requiredSkill)) return next() - return { kind: 'deny', reason: denialReason(target.path, target.branch, requiredSkill) } - }) -} diff --git a/packages/guard/source-guard/src/invariant.ts b/packages/guard/source-guard/src/invariant.ts deleted file mode 100644 index f5b68cf24d..0000000000 --- a/packages/guard/source-guard/src/invariant.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-source-guard`. - * @module @deepseek-ai/dsh-source-guard/invariant - */ - -import type { Context } from 'cordis' -import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' - -const PACKAGE_NAME = '@deepseek-ai/dsh-source-guard' - -/** Cordis companion plugin name. */ -export const name = 'source-guard-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * The durable shape of this guard's refusal. The denial is the package's only - * model-visible output, and it is actionable only when it names all three of - * the offending path, the branch that protects it, and the skill that lifts - * the denial — a refusal missing any of them tells the model to stop without - * telling it how to proceed. - */ -const DENIAL = new RegExp( - '^Error: Editing "(?.+)" directly is not allowed: ' - + 'it is inside the dsh checkout this session is running from, on branch (?\\S+)\\. ' - + 'Load the (?\\S+) skill first and follow it ' - + '— implement in a task worktree, then integrate under the staging lock\\.$', -) - -/** The denial prefix identifying a result this package produced, before its full shape is validated. */ -const DENIAL_PREFIX = 'Error: Editing "' - -/** Validate one guard-produced denial result's model-facing text. */ -function validateDenial(text: string, fail: InvariantFailure): void { - const match = DENIAL.exec(text) - if (match === null) { - fail('source-guard denial must name the path, the protecting branch, and the skill that lifts it') - } - // The pattern's `\S+` groups already establish a non-empty branch and skill; - // only path absoluteness remains to check. - const { path } = match.groups as { path: string } - if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) { - fail(`source-guard denial must name an absolute path, got ${JSON.stringify(path)}`) - } -} - -/** Validate every guard denial carried by one session's durable log. */ -function validateSession(session: Session, fail: InvariantFailure): void { - for (const event of session.events) { - if (event.type !== 'tool/result') continue - validateEvent(event, fail) - } -} - -/** Validate one durable tool result, when it carries this package's denial. */ -function validateEvent(event: SessionEvent<'tool/result'>, fail: InvariantFailure): void { - const result = event.data.message.content[0] - if (result.isError !== true) return - for (const block of result.content) { - if (block.type !== 'text' || !block.text.startsWith(DENIAL_PREFIX)) continue - validateDenial(block.text, fail) - } -} - -/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */ -/** Install validation for loaded and newly appended denial results. */ -const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { - for (const session of ctx.sessions.list()) validateSession(session, fail) - ctx.on('internal/dispatch', (_mode, eventName, args) => { - if (eventName !== 'session/event') return - const [, event] = args as [Session, SessionEvent] - if (event.type !== 'tool/result') return - validateEvent(event, fail) - }, { global: true }) -}, { inject: ['sessions'] }) -/* jscpd:ignore-end */ - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/guard/source-guard/tests/invariant.spec.ts b/packages/guard/source-guard/tests/invariant.spec.ts deleted file mode 100644 index cd51c1a9a1..0000000000 --- a/packages/guard/source-guard/tests/invariant.spec.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import { CallId, createToolResultMessage, createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' -import InvariantService from '@deepseek-ai/dsh-invariants' -import * as SourceGuardInvariant from '@deepseek-ai/dsh-source-guard/invariant' - -/** - * The companion validates the durable shape of this package's only - * model-visible output: its refusal must name the offending path, the branch - * that protects it, and the skill that lifts it, so the model can act on the - * denial instead of merely stopping. - */ - -const PATH = '/repo/staging/file.ts' - -/** A well-formed denial for `path`, as the guard materializes it into a tool result. */ -function denial(path = PATH, branch = 'dsh-staging/20260101T000000Z', skill = 'dsh-customize'): string { - return `Error: Editing "${path}" directly is not allowed: it is inside the dsh checkout this session is running from, ` - + `on branch ${branch}. Load the ${skill} skill first and follow it ` - + '— implement in a task worktree, then integrate under the staging lock.' -} - -async function setup(): Promise { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(InvariantService, { enabled: true }) - await ctx.plugin(SourceGuardInvariant) - return ctx -} - -/** One durable tool result carrying `content`, error-flagged unless told otherwise. */ -function result(content: unknown[], isError = true): SessionEvent { - return { - type: 'tool/result', - seq: 0, - time: 1, - surfaceOp: 'append', - sourceEventSeqs: [0], - data: { - turn: 1, - step: 1, - message: createToolResultMessage({ - callId: CallId('c0'), - content: content as ContentBlock[], - isError, - }), - }, - } -} - -describe('source-guard invariants', () => { - it('accepts a denial naming the path, branch, and skill', async () => { - const ctx = await setup() - const session = ctx.sessions.create(SessionId('accept')) - expect(() => { ctx.emit('session/event', session, result([{ type: 'text', text: denial() }])) }).not.toThrow() - }) - - it('accepts a Windows-style absolute path', async () => { - const ctx = await setup() - const session = ctx.sessions.create(SessionId('accept-windows')) - const event = result([{ type: 'text', text: denial(String.raw`C:\repo\staging\file.ts`) }]) - expect(() => { ctx.emit('session/event', session, event) }).not.toThrow() - }) - - it.each([ - ['a successful result that merely quotes the prefix', false], - ])('ignores %s', async (_label, isError) => { - const ctx = await setup() - const session = ctx.sessions.create(SessionId('ignore-success')) - const event = result([{ type: 'text', text: 'Error: Editing "x" was fine' }], isError) - expect(() => { ctx.emit('session/event', session, event) }).not.toThrow() - }) - - it.each([ - ['a non-text block', [{ type: 'image', data: 'x', mimeType: 'image/png' }]], - ['text that is not this package\'s denial', [{ type: 'text', text: 'Error: something else' }]], - ])('ignores %s', async (_label, content) => { - const ctx = await setup() - const session = ctx.sessions.create(SessionId('ignore-other')) - expect(() => { ctx.emit('session/event', session, result(content)) }).not.toThrow() - }) - - it('ignores an event that is not a tool result', async () => { - const ctx = await setup() - const session = ctx.sessions.create(SessionId('ignore-kind')) - const event: SessionEvent = { - type: 'user/message', - seq: 0, - time: 1, - surfaceOp: 'append', - data: createUserMessage({ content: [{ type: 'text', text: denial() }], source: { kind: 'user' } }), - } - expect(() => { ctx.emit('session/event', session, event) }).not.toThrow() - }) - - it.each([ - [ - 'omits the skill that lifts it', - `Error: Editing "${PATH}" directly is not allowed: it is inside the dsh checkout this session is running from, on branch main.`, - ], - [ - 'names a relative path', - denial('relative/file.ts'), - ], - ])('rejects a denial that %s', async (_label, text) => { - const ctx = await setup() - const session = ctx.sessions.create(SessionId('reject')) - expect(() => { ctx.emit('session/event', session, result([{ type: 'text', text }])) }).toThrow(/source-guard denial/) - }) - - it('rejects an invalid denial already present on late registration', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = ctx.sessions.create(SessionId('late')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - const call = session.append('tool/call', { - turn: 1, step: 1, callId: CallId('c0'), name: 'write', arguments: '{}', - }) - session.append('tool/result', { - turn: 1, - step: 1, - message: createToolResultMessage({ - callId: CallId('c0'), - content: [{ type: 'text', text: denial('relative/file.ts') }], - isError: true, - }), - }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] }) - - await ctx.plugin(InvariantService, { enabled: true }) - await expect(ctx.plugin(SourceGuardInvariant).then(() => undefined)).rejects.toThrow(/source-guard denial/) - }) -}) diff --git a/packages/guard/source-guard/tests/loader-composition.e2e.ts b/packages/guard/source-guard/tests/loader-composition.e2e.ts deleted file mode 100644 index 2595dc8d2a..0000000000 --- a/packages/guard/source-guard/tests/loader-composition.e2e.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { type SessionEvent } from '@deepseek-ai/dsh-session' -import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' - -// The Loader config lives under examples so both launch modes exercise the same -// deployable topology: a local fixture adapter plus bare workspace plugins. -const configPath = fileURLToPath(new URL( - '../../../../examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml', - import.meta.url, -)) -const binScript = fileURLToPath(new URL('../../../examples/cli-demo/src/bin.ts', import.meta.url)) -const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) - -/** Every `.jsonl` session log under `dir`. */ -async function jsonlFiles(dir: string): Promise { - const entries = await readdir(dir, { withFileTypes: true }) - const paths = await Promise.all(entries.map(async (entry) => { - const path = join(dir, entry.name) - if (entry.isDirectory()) return jsonlFiles(path) - return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] - })) - return paths.flat() -} - -/** - * Write git metadata mirroring the installer layout — a master clone owning the - * shared git directory and one linked worktree on a staging branch — and return - * the worktree file the model will try to write. - */ -async function stagingFixture(cwd: string): Promise<{ checkout: string; target: string }> { - const gitDir = join(cwd, 'master', '.git') - const worktreeGitDir = join(gitDir, 'worktrees', 'staging') - await mkdir(worktreeGitDir, { recursive: true }) - await writeFile(join(gitDir, 'HEAD'), 'ref: refs/heads/master\n') - await writeFile(join(worktreeGitDir, 'HEAD'), 'ref: refs/heads/dsh-staging/20260101T000000Z\n') - const checkout = join(cwd, 'staging') - await mkdir(checkout, { recursive: true }) - await writeFile(join(checkout, '.git'), `gitdir: ${worktreeGitDir}\n`) - const target = join(checkout, 'guarded.ts') - await writeFile(target, 'original\n') - return { checkout, target } -} - -describe('source-guard through a real headless cordis.yml', () => { - it('denies the model-requested write and leaves the staged file untouched', async () => { - let events: SessionEvent[] = [] - let contents = '' - let target = '' - const { stderr } = await runLoaderSmoke({ - label: 'source-guard headless smoke', - tempDirPrefix: 'source-guard-e2e-', - binScript, - configPath, - tsconfigPath: repoTsconfig, - binArgs: ['--config', configPath, 'edit the guarded file'], - // The isolated cwd is not known when these options are built, so the - // config and adapter resolve their fixture paths against the child's own - // cwd, which is that directory. - prepare: async (cwd) => { - // macOS puts the temp directory behind the /var -> /private/var - // symlink; the child resolves its cwd, so compare against the same - // real path rather than the symlinked one this process was handed. - target = (await stagingFixture(await realpath(cwd))).target - }, - inspect: async (cwd) => { - const logs = await jsonlFiles(join(cwd, '.sessions')) - expect(logs).toHaveLength(1) - const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') - events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) - contents = await readFile(target, 'utf8') - }, - }) - expect(stderr).not.toContain('UNHANDLED') - - const results = events.filter( - (event): event is SessionEvent<'tool/result'> => event.type === 'tool/result') - expect(results).toHaveLength(1) - const result = results[0]?.data.message.content[0] - expect(result?.isError).toBe(true) - const text = result?.content.map(block => block.type === 'text' ? block.text : '').join('') - expect(text).toBe( - `Error: Editing "${target}" directly is not allowed: it is inside the dsh checkout this session is running from, ` - + 'on branch dsh-staging/20260101T000000Z. Load the dsh-customize skill first and follow it ' - + '— implement in a task worktree, then integrate under the staging lock.', - ) - // Enforcement, not advice: the guard denies before dispatch, so the file - // the model targeted still holds its original bytes. - expect(contents).toBe('original\n') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) -}) diff --git a/packages/guard/source-guard/tests/source-guard.spec.ts b/packages/guard/source-guard/tests/source-guard.spec.ts deleted file mode 100644 index ccbc7d7c2b..0000000000 --- a/packages/guard/source-guard/tests/source-guard.spec.ts +++ /dev/null @@ -1,581 +0,0 @@ -import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import LocalFileSystem from '@deepseek-ai/dsh-fs-local' -import { CallId, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' -import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' -import type { Agent } from '@deepseek-ai/dsh-agent' -import * as SourceGuard from '@deepseek-ai/dsh-source-guard' -import type { Config } from '@deepseek-ai/dsh-source-guard' -import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' - -/** - * Behavior suite for the staging-source guard: worktree resolution over REAL - * git metadata fixtures (a staging worktree, a nested task worktree, a plain - * clone, an unrelated repository, a detached HEAD), skill satisfaction replayed - * from the durable session log, and fail-loud config validation — all driven - * through a real agent loop against a scripted mock adapter (no network). - */ - -const roots: string[] = [] - -afterEach(async () => { - await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) -}) - -/** - * Build a git-metadata fixture tree that mirrors the real installer layout: a - * `master` clone holding the shared git directory, linked worktrees registered - * under `master/.git/worktrees/`, and one file per worktree to target. - */ -async function fixture(): Promise<{ - /** Absolute path of the fixture container. */ - root: string - /** A file inside the staging worktree — the protected target. */ - stagingFile: string - /** A file inside a task worktree NESTED under the staging tree. */ - taskFile: string - /** A file inside a SIBLING staging worktree of the same repository, on another branch. */ - siblingFile: string - /** A file inside the plain master clone. */ - masterFile: string - /** A file inside a worktree whose HEAD is detached. */ - detachedFile: string - /** A file inside an unrelated repository sharing no git directory. */ - outsideFile: string - /** A file under no repository at all. */ - looseFile: string -}> { - const root = await mkdtemp(join(tmpdir(), 'source-guard-')) - roots.push(root) - const master = join(root, 'master') - const gitDir = join(master, '.git') - await mkdir(join(gitDir, 'worktrees'), { recursive: true }) - await writeFile(join(gitDir, 'HEAD'), 'ref: refs/heads/master\n') - await writeFile(join(master, 'file.ts'), 'master\n') - - /** Register one linked worktree at `path` whose HEAD file holds `head`. */ - async function linked(path: string, name: string, head: string): Promise { - const worktreeGitDir = join(gitDir, 'worktrees', name) - await mkdir(worktreeGitDir, { recursive: true }) - await writeFile(join(worktreeGitDir, 'HEAD'), head) - await mkdir(path, { recursive: true }) - await writeFile(join(path, '.git'), `gitdir: ${worktreeGitDir}\n`) - const file = join(path, 'file.ts') - await writeFile(file, 'content\n') - return file - } - - const staging = join(root, 'staging-20260728T022827Z') - const stagingFile = await linked(staging, 'staging-20260728T022827Z', 'ref: refs/heads/dsh-staging/20260728T022827Z\n') - // The prescribed workflow's task worktree lives INSIDE the staging tree. - const taskFile = await linked(join(staging, '.worktrees', 'task', 'x'), 'task-x', 'ref: refs/heads/task/x\n') - // A stale staging worktree from an earlier install: same repository, different branch. - const siblingFile = await linked( - join(root, 'staging-20260727T045831Z'), - 'staging-20260727T045831Z', - 'ref: refs/heads/dsh-staging/20260727T045831Z\n', - ) - const detachedFile = await linked(join(root, 'detached'), 'detached', '0123456789abcdef0123456789abcdef01234567\n') - - const outside = join(root, 'outside') - await mkdir(join(outside, '.git'), { recursive: true }) - await writeFile(join(outside, '.git', 'HEAD'), 'ref: refs/heads/dsh-staging/20260728T022827Z\n') - const outsideFile = join(outside, 'file.ts') - await writeFile(outsideFile, 'outside\n') - - const loose = join(root, 'loose') - await mkdir(loose, { recursive: true }) - const looseFile = join(loose, 'file.ts') - await writeFile(looseFile, 'loose\n') - - return { - root, stagingFile, taskFile, siblingFile, masterFile: join(master, 'file.ts'), detachedFile, outsideFile, looseFile, - } -} - -/** - * Boot the core spine, a real local filesystem, and the guard, pointing - * `protectedCheckout` at a fixture path so the guard arms for the fixture - * repository instead of the checkout these tests actually run in. - */ -async function harness(protectedCheckout: string, config: Partial = {}): Promise { - const ctx = new Context() - await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(LocalFileSystem, {}) - await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(SourceGuard, { ...config, protectedCheckout }) - for (const name of ['write', 'edit', 'read', 'skill']) { - ctx.tools.register(defineContentToolFixture({ - name, - description: name, - parameters: { file_path: { type: 'string' }, name: { type: 'string' } }, - async execute() { return [{ type: 'text', text: 'ok' }] }, - })) - } - return ctx -} - -function waitForIdle(ctx: Context, agent: Agent): Promise { - return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { - if (subject === agent && status === 'idle') { dispose(); resolve() } - }) - }) -} - -/** Every tool result in the agent's log as `{ isError, text }`, in log order. */ -function results(agent: Agent): { isError: boolean; text: string }[] { - return [...agent.session.events] - .filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result') - .map(event => event.data.message.content[0]) - .map(result => ({ - isError: result.isError === true, - text: result.content.map(block => block.type === 'text' ? block.text : '').join(''), - })) -} - -/** - * Durable events recording completed `skill` calls, as a RESUMED session's seed: - * the guard's satisfaction check then has nothing but the log to read, with no - * in-memory state from an original run to fall back on. - */ -function priorSkillCalls(calls: { arguments: string; isError?: boolean }[]): SessionEvent[] { - const events: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, - { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, - ] - for (const [index, call] of calls.entries()) { - const callId = CallId(`prior${index}`) - const seq = events.length - events.push({ - type: 'tool/call', - seq, - time: seq + 1, - data: { turn: 1, step: 1, callId, name: 'skill', arguments: call.arguments }, - }) - events.push({ - type: 'tool/result', - seq: seq + 1, - time: seq + 2, - surfaceOp: 'append', - sourceEventSeqs: [seq], - data: { - turn: 1, - step: 1, - message: createToolResultMessage({ - callId, - content: [{ type: 'text', text: 'loaded' }], - isError: call.isError ?? false, - }), - }, - }) - } - const tail = events.length - events.push({ type: 'step/end', seq: tail, time: tail + 1, data: { turn: 1, step: 1 } }) - events.push({ type: 'turn/end', seq: tail + 1, time: tail + 2, data: { turn: 1, reason: { kind: 'completed' } } }) - return events -} - -/** Resume a session from durable seed events and let the model attempt one write at `path`. */ -async function resume(ctx: Context, id: string, seed: SessionEvent[], path: string): Promise { - const adapter = new MockAdapter([ - toolCallResponse(CallId('c0'), 'write', { file_path: path }), - textResponse('done'), - ]) - ctx.llm.registerAdapter(['mock'], adapter) - const { agent } = await ctx.agentLoop.createAgent(ctx, { - sessionId: SessionId(id), - seed, - agentOptions: { provider: 'mock', model: 'mock' }, - }) - agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) - await waitForIdle(ctx, agent) - return agent -} - -/** Drive one turn whose scripted model output is the given tool calls, then a closing text. */ -async function run( - ctx: Context, - calls: { name: string; args: Record }[], - cwd?: string, -): Promise { - const adapter = new MockAdapter([ - ...calls.map((call, index) => toolCallResponse(CallId(`c${index}`), call.name, call.args)), - textResponse('done'), - ]) - ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create( - SessionId('s1'), - { provider: 'mock', model: 'mock' }, - cwd === undefined ? {} : { cwd }, - ) - agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) - await waitForIdle(ctx, agent) - return agent -} - -describe('staging protection', () => { - it('denies a write inside the staging worktree and names the path, branch, and skill', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }]) - const [result] = results(agent) - expect(result?.isError).toBe(true) - expect(result?.text).toBe( - `Error: Editing "${paths.stagingFile}" directly is not allowed: it is inside the dsh checkout this session is running from, ` - + 'on branch dsh-staging/20260728T022827Z. Load the dsh-customize skill first and follow it ' - + '— implement in a task worktree, then integrate under the staging lock.', - ) - }) - - it('denies an edit inside the staging worktree', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'edit', args: { file_path: paths.stagingFile } }]) - expect(results(agent)[0]?.isError).toBe(true) - }) - - it('allows a read inside the staging worktree, since inspection never violates the skill', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'read', args: { file_path: paths.stagingFile } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it('allows a write inside a task worktree nested under the staging tree', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.taskFile } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it('allows a write in the plain clone that owns the shared git directory', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.masterFile } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it('allows a write on a staging-named branch in an unrelated repository', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.outsideFile } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it('allows a write under a detached HEAD, which names no branch to match', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.detachedFile } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it('allows a write when the git metadata exists but cannot be read', async () => { - const paths = await fixture() - // A `.git` pointer that stats as a file yet fails to read leaves the guard - // with no branch to judge; failing open beats blocking every edit. - const unreadable = join(paths.root, 'unreadable') - await mkdir(unreadable, { recursive: true }) - await writeFile(join(unreadable, '.git'), `gitdir: ${join(paths.root, 'master', '.git')}\n`) - await chmod(join(unreadable, '.git'), 0o000) - const file = join(unreadable, 'file.ts') - await writeFile(file, 'content\n') - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it('allows a write under no repository at all', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.looseFile } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it('arms for nothing when its own location is inside no repository', async () => { - const paths = await fixture() - const ctx = await harness(paths.looseFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it('arms for nothing when the launcher checkout has a detached HEAD', async () => { - const paths = await fixture() - // A detached launcher names no branch, so there is no branch to protect. - const ctx = await harness(paths.detachedFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it('denies when the target and the protected checkout reach one repository through different symlinks', async () => { - const paths = await fixture() - // macOS reaches the temp directory through both `/var/...` and - // `/private/var/...`; a lexical repository comparison would treat the two - // routes as different repositories and fail open on every write. - const link = join(paths.root, 'link') - await symlink(dirname(paths.stagingFile), link, 'dir') - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: join(link, 'file.ts') } }]) - expect(results(agent)[0]?.text).toContain('on branch dsh-staging/20260728T022827Z') - }) - - it('denies a RELATIVE target path resolved against the session workspace', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - // The filesystem tools resolve a relative `file_path` against the session - // cwd, so judging only absolute paths would leave this as an unguarded - // route to the same file. - const agent = await run(ctx, [{ name: 'write', args: { file_path: 'file.ts' } }], dirname(paths.stagingFile)) - expect(results(agent)[0]?.text).toContain('directly is not allowed') - }) - - it('ignores a relative target path when the session names no workspace', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: 'file.ts' } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it('ignores a call whose target path is an empty string', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: '' } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it.each([ - ['a non-string file_path', { file_path: 7 }], - ['no file_path at all', { other: 'x' }], - ])('ignores a gated call carrying %s', async (_label, args) => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args }]) - expect(results(agent)[0]?.text).not.toContain('directly is not allowed') - }) - - it('ignores a gated call whose arguments are not JSON, which the loop keeps as raw text', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const callId = CallId('raw') - const adapter = new MockAdapter([ - [ - { type: 'block-start', index: 0, blockType: 'tool-call' }, - { type: 'tool-call-delta', index: 0, id: callId, name: 'write', argumentsDelta: 'not json' }, - { type: 'block-end', index: 0, block: { type: 'tool-call', id: callId, name: 'write', arguments: 'not json' } }, - { type: 'finish', reason: { kind: 'tool-calls' } }, - ], - textResponse('done'), - ]) - ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(SessionId('raw'), { provider: 'mock', model: 'mock' }) - agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) - await waitForIdle(ctx, agent) - expect(results(agent)[0]?.text).not.toContain('directly is not allowed') - }) - - it.each([ - ['a `.git` pointer that names no git directory', 'not a gitdir pointer\n'], - ['an empty `.git` pointer', 'gitdir:\n'], - ['a `.git` pointer into a nonexistent git directory', 'gitdir: /nonexistent/worktrees/x\n'], - ])('allows a write behind %s', async (_label, pointer) => { - const paths = await fixture() - const broken = join(paths.root, 'broken') - await mkdir(broken, { recursive: true }) - await writeFile(join(broken, '.git'), pointer) - const file = join(broken, 'file.ts') - await writeFile(file, 'content\n') - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it('denies behind a RELATIVE `.git` pointer, which git resolves against the worktree', async () => { - const paths = await fixture() - // `git worktree add` writes an absolute pointer, but a relocated or - // hand-written one may be relative; git accepts both, so the guard must - // resolve both or it would fail open on a real repository layout. - const relative = join(paths.root, 'relative-pointer') - await mkdir(relative, { recursive: true }) - await writeFile(join(relative, '.git'), 'gitdir: ../master/.git/worktrees/staging-20260728T022827Z\n') - const file = join(relative, 'file.ts') - await writeFile(file, 'content\n') - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }]) - expect(results(agent)[0]?.text).toContain('on branch dsh-staging/20260728T022827Z') - }) - - it('allows a write when the worktree resolves but its HEAD is missing', async () => { - const paths = await fixture() - const gitDir = join(paths.root, 'master', '.git', 'worktrees', 'headless') - await mkdir(gitDir, { recursive: true }) - const headless = join(paths.root, 'headless') - await mkdir(headless, { recursive: true }) - await writeFile(join(headless, '.git'), `gitdir: ${gitDir}\n`) - const file = join(headless, 'file.ts') - await writeFile(file, 'content\n') - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it('reuses one resolution for sibling targets in the same directory', async () => { - const paths = await fixture() - const sibling = join(dirname(paths.stagingFile), 'other.ts') - await writeFile(sibling, 'content\n') - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [ - { name: 'write', args: { file_path: paths.stagingFile } }, - { name: 'write', args: { file_path: sibling } }, - ]) - expect(results(agent).map(result => result.isError)).toEqual([true, true]) - }) - - it('protects whichever branch the launcher checkout is on, whatever its name', async () => { - const paths = await fixture() - // The protected branch is read from `protectedCheckout`'s own worktree, so - // a checkout on an unconventional branch name is still protected — a - // hardcoded name pattern would have silently guarded nothing. - const ctx = await harness(paths.taskFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.taskFile } }]) - expect(results(agent)[0]?.text).toContain('on branch task/x') - }) - - it('allows a write in a SIBLING checkout of the same repository on another branch', async () => { - const paths = await fixture() - // A stale staging worktree left by an earlier install shares the - // repository but is not the live deployment, so the workflow rule the - // guard enforces does not apply to it. - const ctx = await harness(paths.siblingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it('gates only the configured tools', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile, { tools: ['edit'] }) - const agent = await run(ctx, [ - { name: 'write', args: { file_path: paths.stagingFile } }, - { name: 'edit', args: { file_path: paths.stagingFile } }, - ]) - expect(results(agent).map(result => result.isError)).toEqual([false, true]) - }) -}) - -describe('skill satisfaction', () => { - it('allows the write after a successful load of the required skill in the same turn', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [ - { name: 'skill', args: { name: 'dsh-customize' } }, - { name: 'write', args: { file_path: paths.stagingFile } }, - ]) - expect(results(agent)).toEqual([ - { isError: false, text: 'ok' }, - { isError: false, text: 'ok' }, - ]) - }) - - it('allows the write when the skill load is only in the REPLAYED log, so resume keeps satisfaction', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const seed = priorSkillCalls([{ arguments: JSON.stringify({ name: 'dsh-customize' }) }]) - const agent = await resume(ctx, 'resumed', seed, paths.stagingFile) - expect(results(agent).at(-1)).toEqual({ isError: false, text: 'ok' }) - }) - - it('does not accept a failed skill load', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const seed = priorSkillCalls([{ arguments: JSON.stringify({ name: 'dsh-customize' }), isError: true }]) - const agent = await resume(ctx, 'failed', seed, paths.stagingFile) - expect(results(agent).at(-1)?.isError).toBe(true) - }) - - it('does not accept a different skill', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [ - { name: 'skill', args: { name: 'dsh-upgrade' } }, - { name: 'write', args: { file_path: paths.stagingFile } }, - ]) - expect(results(agent).map(result => result.isError)).toEqual([false, true]) - }) - - it('does not accept a skill call whose arguments are not a JSON object naming a string', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const seed = priorSkillCalls([ - { arguments: 'not json' }, - { arguments: '[]' }, - { arguments: '{"name":7}' }, - { arguments: 'null' }, - ]) - const agent = await resume(ctx, 'malformed', seed, paths.stagingFile) - expect(results(agent).at(-1)?.isError).toBe(true) - }) - - it('honours a configured skill name other than the default', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile, { requiredSkill: 'other-skill' }) - const agent = await run(ctx, [ - { name: 'skill', args: { name: 'other-skill' } }, - { name: 'write', args: { file_path: paths.stagingFile } }, - ]) - expect(results(agent).map(result => result.isError)).toEqual([false, false]) - }) -}) - -describe('non-agent callers', () => { - it('leaves a direct registry call ungated, having no session to replay', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const result = await ctx.tools.execute({ - callId: CallId('direct'), - name: 'write', - arguments: { file_path: paths.stagingFile }, - signal: new AbortController().signal, - }) - expect(result.isError).toBe(false) - }) -}) - -describe('config validation', () => { - it.each([ - ['tools', { tools: [] }, '`tools` must not be empty'], - ['requiredSkill', { requiredSkill: ' ' }, '`requiredSkill` must not be blank'], - ['protectedCheckout', { protectedCheckout: 'relative/path' }, '`protectedCheckout` must be an absolute path'], - ])('rejects an invalid %s at load', async (_field, config, message) => { - const ctx = new Context() - await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(LocalFileSystem, {}) - await expect(ctx.plugin(SourceGuard, config as Config)).rejects.toThrow(message) - }) -}) - -describe('disposal', () => { - it('stops gating once the plugin fiber is disposed', async () => { - const paths = await fixture() - const ctx = new Context() - await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(LocalFileSystem, {}) - await ctx.plugin(AgentLoop, { agents: [] }) - const fiber = await ctx.plugin(SourceGuard, { protectedCheckout: paths.stagingFile }) - for (const name of ['write', 'skill']) { - ctx.tools.register(defineContentToolFixture({ - name, - description: name, - parameters: { file_path: { type: 'string' }, name: { type: 'string' } }, - async execute() { return [{ type: 'text', text: 'ok' }] }, - })) - } - await fiber.dispose() - const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) -}) diff --git a/packages/guard/source-guard/tsconfig.json b/packages/guard/source-guard/tsconfig.json deleted file mode 100644 index 133b7fefdf..0000000000 --- a/packages/guard/source-guard/tsconfig.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cosmokit" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../../vendor/schemastery" - }, - { - "path": "../../core/tools" - }, - { - "path": "../../core/agent" - }, - { - "path": "../../core/session" - }, - { - "path": "../../fs/fs" - }, - { - "path": "../../llm/llm" - }, - { - "path": "../../sandbox/sandbox" - }, - { - "path": "../../support/invariants" - } - ] -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9281011c6..8dc9613dc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -296,9 +296,6 @@ importers: '@deepseek-ai/dsh-skill-local': specifier: workspace:^ version: link:../../packages/skill/skill-local - '@deepseek-ai/dsh-source-guard': - specifier: workspace:^ - version: link:../../packages/guard/source-guard '@deepseek-ai/dsh-spill-local': specifier: workspace:^ version: link:../../packages/spill/spill-local @@ -335,9 +332,6 @@ importers: '@deepseek-ai/dsh-timeout-policy': specifier: workspace:^ version: link:../../packages/timeout/timeout-policy - '@deepseek-ai/dsh-tmux-context': - specifier: workspace:^ - version: link:../../packages/context/tmux-context '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../packages/llm/token-meter @@ -613,9 +607,6 @@ importers: '@deepseek-ai/dsh-skill-local': specifier: workspace:* version: link:../packages/skill/skill-local - '@deepseek-ai/dsh-source-guard': - specifier: workspace:* - version: link:../packages/guard/source-guard '@deepseek-ai/dsh-spill-local': specifier: workspace:* version: link:../packages/spill/spill-local @@ -652,9 +643,6 @@ importers: '@deepseek-ai/dsh-timeout-policy': specifier: workspace:* version: link:../packages/timeout/timeout-policy - '@deepseek-ai/dsh-tmux-context': - specifier: workspace:* - version: link:../packages/context/tmux-context '@deepseek-ai/dsh-token-meter': specifier: workspace:* version: link:../packages/llm/token-meter @@ -1963,37 +1951,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/context/tmux-context: - dependencies: - schemastery: - specifier: ^3.18.0 - version: 3.18.0 - devDependencies: - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-bash': - specifier: workspace:^ - version: link:../../bash/bash - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-loader-smoke': - specifier: workspace:^ - version: link:../../support/loader-smoke - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/context/workspace-context: dependencies: schemastery: @@ -2784,49 +2741,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/guard/source-guard: - dependencies: - schemastery: - specifier: ^3.18.0 - version: 3.18.0 - devDependencies: - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../../core/agent-loop - '@deepseek-ai/dsh-agent-loop-testkit': - specifier: workspace:^ - version: link:../../support/agent-loop-testkit - '@deepseek-ai/dsh-fs': - specifier: workspace:^ - version: link:../../fs/fs - '@deepseek-ai/dsh-fs-local': - specifier: workspace:^ - version: link:../../fs/fs-local - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-loader-smoke': - specifier: workspace:^ - version: link:../../support/loader-smoke - '@deepseek-ai/dsh-sandbox': - specifier: workspace:^ - version: link:../../sandbox/sandbox - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/hooks/hook-protocol: devDependencies: '@deepseek-ai/dsh-bash': diff --git a/tsconfig.host.json b/tsconfig.host.json index 51ed1cdc3b..b0121f2a0a 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -86,7 +86,6 @@ { "path": "./packages/goal/goal-session" }, { "path": "./packages/goal/command-goal" }, { "path": "./packages/context/time-context" }, - { "path": "./packages/context/tmux-context" }, { "path": "./packages/context/session-reference" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, @@ -165,7 +164,6 @@ { "path": "./packages/todo/tool-todo" }, { "path": "./packages/plan/plan-mode" }, { "path": "./packages/guard/repeat-tool-guard" }, - { "path": "./packages/guard/source-guard" }, { "path": "./packages/cordis/tool-cordis" }, { "path": "./packages/hooks/hook-protocol" }, { "path": "./packages/hooks/hooks-claude" }, From 9e3383f727751803d176a2857965ac0253219e7f Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 15:44:55 +0800 Subject: [PATCH 11/77] docs: regenerate catalogs after feature exclusions --- docs/config-catalog.md | 81 ++++++++++----------------------- docs/event-producer-consumer.md | 8 ++-- 2 files changed, 27 insertions(+), 62 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3c0543e4ca..870c00e051 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -108,7 +108,7 @@ export interface Config { Depends on: [`AgentOptions`](core-data-structures/core.md) · [`SessionId`](core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:155`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:211`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-agent-spine-demo` @@ -1153,6 +1153,26 @@ export interface Config { Source: [`packages/context/session-reference/src/config.ts:11`](../packages/context/session-reference/src/config.ts) +## `@deepseek-ai/dsh-session-registry-file` + +```ts config-catalog +/** + * Plugin config as callers write it: `root` is required — a cwd fallback would + * scatter registries — while the lock tunables are optional because + * `static Config` supplies their defaults. + */ +export interface Config { + /** Directory holding the registry file; created `0o700` on demand. */ + root: string + /** Milliseconds after which a held lock is considered abandoned and reclaimed. */ + lockStaleMs?: number + /** Retries before a contended acquisition fails loud. */ + lockRetries?: number +} +``` + +Source: [`packages/session-registry/session-registry-file/src/index.ts:43`](../packages/session-registry/session-registry-file/src/index.ts) + ## `@deepseek-ai/dsh-session-telemetry-otel` Requires: `sessions` @@ -1939,63 +1959,6 @@ export interface TuiThemeConfig { Source: [`packages/ui/tui/src/config.ts:117`](../packages/ui/tui/src/config.ts) -## `@deepseek-ai/dsh-tui-demo` - -```ts config-catalog -/** 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 - /** 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 - /** Generic background-task controls forwarded through agent-spine-demo; set false to omit them. */ - toolTasks?: NonNullable - /** 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'] -} -``` - -Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`SessionReferenceConfig`](#deepseek-aidsh-session-reference) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) - -Source: [`packages/examples/tui-demo/src/index.ts:39`](../packages/examples/tui-demo/src/index.ts) - ## `@deepseek-ai/dsh-user-approval` ```ts config-catalog @@ -2231,6 +2194,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-session-projection` ([`packages/session-projection/session-projection/src/index.ts`](../packages/session-projection/session-projection/src/index.ts)) +- `@deepseek-ai/dsh-session-registry-live` — requires `sessions` · `sessionRegistry` ([`packages/session-registry/session-registry-live/src/index.ts`](../packages/session-registry/session-registry-live/src/index.ts)) - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts)) @@ -2253,6 +2217,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) - `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts)) +- `@deepseek-ai/dsh-session-registry` — abstract `SessionRegistry` ([`packages/session-registry/session-registry/src/index.ts`](../packages/session-registry/session-registry/src/index.ts)) - `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts)) - `@deepseek-ai/dsh-subprocess` — abstract `SubprocessService` ([`packages/subprocess/subprocess/src/index.ts`](../packages/subprocess/subprocess/src/index.ts)) - `@deepseek-ai/dsh-tasks` — abstract `TaskService` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts)) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 71238305e5..13448e2c5b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:148`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:157`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | | `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:286`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:218`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:227`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | @@ -31,9 +31,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | From 991c1f14b639d06ee29cb08672457ae7d9301d8a Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 15:46:30 +0800 Subject: [PATCH 12/77] docs: remove retired TUI demo graph row --- docs/module-graph.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index d3e60978e7..f20dd1c5fd 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1127,6 +1127,5 @@ flowchart TD | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`session-reference`](../packages/context/session-reference), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | From ea9315841fca10430e940e74ea167ec88d9e49bc Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 16:28:17 +0800 Subject: [PATCH 13/77] refactor(session): exclude live-session registry foundation --- ...026-07-28-cross-workspace-resume.i18n.yaml | 4 +- .../2026-07-28-cross-workspace-resume.md | 1 - .../2026-07-28-cross-workspace-resume.zh.md | 1 - ...live-session-registry-and-dsh-ls.i18n.yaml | 6 - ...-07-28-live-session-registry-and-dsh-ls.md | 65 --- ...-28-live-session-registry-and-dsh-ls.zh.md | 65 --- apps/cli/base.cordis.yml | 2 +- docs/capability-seams.md | 8 - docs/config-catalog.md | 22 - docs/cordis-catalog/services.md | 38 -- docs/event-producer-consumer.md | 6 +- knip.json | 1 - .../cordis/tool-cordis/src/api-catalog.ts | 22 - packages/session-registry/README.i18n.yaml | 6 - packages/session-registry/README.md | 15 - packages/session-registry/README.zh.md | 15 - .../session-registry-file/README.i18n.yaml | 6 - .../session-registry-file/README.md | 42 -- .../session-registry-file/README.zh.md | 42 -- .../session-registry-file/package.json | 47 --- .../session-registry-file/src/file.ts | 97 ----- .../session-registry-file/src/index.ts | 233 ----------- .../session-registry-file/src/invariant.ts | 32 -- .../session-registry-file/src/liveness.ts | 30 -- .../tests/fixtures/register-once.ts | 27 -- .../tests/session-registry-file.spec.ts | 382 ------------------ .../session-registry-file/tsconfig.json | 21 - .../session-registry-live/README.i18n.yaml | 6 - .../session-registry-live/README.md | 32 -- .../session-registry-live/README.zh.md | 32 -- .../session-registry-live/package.json | 44 -- .../session-registry-live/src/index.ts | 84 ---- .../session-registry-live/src/invariant.ts | 31 -- .../tests/session-registry-live.spec.ts | 227 ----------- .../session-registry-live/tsconfig.json | 24 -- .../session-registry/README.i18n.yaml | 6 - .../session-registry/README.md | 30 -- .../session-registry/README.zh.md | 30 -- .../session-registry/package.json | 41 -- .../session-registry/src/index.ts | 82 ---- .../session-registry/src/invariant.ts | 58 --- .../session-registry/src/types.ts | 55 --- .../session-registry/tests/invariant.spec.ts | 98 ----- .../session-registry/tsconfig.json | 21 - pnpm-lock.yaml | 96 ----- scripts/gen-cordis-catalog.ts | 2 - scripts/gen-doc-graphs.ts | 9 - .../verify-package-readme-model-experience.ts | 3 - tsconfig.base.json | 2 - tsconfig.host.json | 3 - 50 files changed, 6 insertions(+), 2246 deletions(-) delete mode 100644 .agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md delete mode 100644 .agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.zh.md delete mode 100644 packages/session-registry/README.i18n.yaml delete mode 100644 packages/session-registry/README.md delete mode 100644 packages/session-registry/README.zh.md delete mode 100644 packages/session-registry/session-registry-file/README.i18n.yaml delete mode 100644 packages/session-registry/session-registry-file/README.md delete mode 100644 packages/session-registry/session-registry-file/README.zh.md delete mode 100644 packages/session-registry/session-registry-file/package.json delete mode 100644 packages/session-registry/session-registry-file/src/file.ts delete mode 100644 packages/session-registry/session-registry-file/src/index.ts delete mode 100644 packages/session-registry/session-registry-file/src/invariant.ts delete mode 100644 packages/session-registry/session-registry-file/src/liveness.ts delete mode 100644 packages/session-registry/session-registry-file/tests/fixtures/register-once.ts delete mode 100644 packages/session-registry/session-registry-file/tests/session-registry-file.spec.ts delete mode 100644 packages/session-registry/session-registry-file/tsconfig.json delete mode 100644 packages/session-registry/session-registry-live/README.i18n.yaml delete mode 100644 packages/session-registry/session-registry-live/README.md delete mode 100644 packages/session-registry/session-registry-live/README.zh.md delete mode 100644 packages/session-registry/session-registry-live/package.json delete mode 100644 packages/session-registry/session-registry-live/src/index.ts delete mode 100644 packages/session-registry/session-registry-live/src/invariant.ts delete mode 100644 packages/session-registry/session-registry-live/tests/session-registry-live.spec.ts delete mode 100644 packages/session-registry/session-registry-live/tsconfig.json delete mode 100644 packages/session-registry/session-registry/README.i18n.yaml delete mode 100644 packages/session-registry/session-registry/README.md delete mode 100644 packages/session-registry/session-registry/README.zh.md delete mode 100644 packages/session-registry/session-registry/package.json delete mode 100644 packages/session-registry/session-registry/src/index.ts delete mode 100644 packages/session-registry/session-registry/src/invariant.ts delete mode 100644 packages/session-registry/session-registry/src/types.ts delete mode 100644 packages/session-registry/session-registry/tests/invariant.spec.ts delete mode 100644 packages/session-registry/session-registry/tsconfig.json diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml index 848b39e600..cd42eaf755 100644 --- a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md -2026-07-28-cross-workspace-resume.md: d559b73a5ba0f8136d20ef6dcf7c62989d1527e9 -2026-07-28-cross-workspace-resume.zh.md: 404b81cbc07a455e5553a9c227d663e491456c9e +2026-07-28-cross-workspace-resume.md: be455496346b9242585c1aace18f4f55cba905c0 +2026-07-28-cross-workspace-resume.zh.md: 0384cf204e51e4086b05c75e691f59d6b60a7d11 diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md index d559b73a5b..be45549634 100644 --- a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md @@ -43,7 +43,6 @@ The shared base states that precedence in the row itself: `apps/cli/base.cordis. ## Consequences - Sessions already stored under a project-local `./.sessions` disappear from `/resume`. This is the accepted cost of no migration. -- One shared root makes the pre-existing absence of a cross-process session lock reachable in one step: colliding used to require two terminals in the same directory, and is now one Tab away. `record.live` comes from the in-process `SessionQueryService`, so preflight rejects only sessions live in *this* runtime, while the JSONL backend takes no lock and two processes appending one log with independent `seq` counters would interleave. Closing this is no longer speculative hardening: `SessionRegistry.list()` already publishes live sessions cross-process under the same Harness home for `dsh list-sessions`, so consulting it in `summarizeResumeCandidate` is a small follow-up. It stays out of this change as pre-existing scope. - A resumed session can change the process's working directory, so a foreign resume is not a pure transcript restoration — every path-resolving tool moves with it. - The Harness home now holds session logs for every project on the machine. Its growth is no longer bounded by one checkout, and no retention policy is introduced here. diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md index 404b81cbc0..0384cf204e 100644 --- a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md @@ -43,7 +43,6 @@ dsh 启动器通过启动槽位提供其 Harness home 下的同一个会话根 ## Consequences - 已经存放在项目本地 `./.sessions` 下的会话会从 `/resume` 中消失。这是不做迁移所接受的代价。 -- 同一个共享根目录让原本就缺失的跨进程会话锁一步之内即可触达:过去要造成冲突需要在同一个目录里开两个终端,如今只差一次 Tab。`record.live` 来自进程内的 `SessionQueryService`,因此预检只会拒绝在*本*运行时中处于活跃状态的会话,而 JSONL 后端不加任何锁,两个进程用各自独立的 `seq` 计数器追加同一份日志会互相交错。解决这一点已不再是投机性加固:`SessionRegistry.list()` 已经为 `dsh list-sessions` 在同一个 Harness home 下跨进程发布活跃会话,因此在 `summarizeResumeCandidate` 中查询它是一项小的后续工作。它作为既有范围之外的问题不纳入本次改动。 - 恢复一个会话可以改变进程的工作目录,因此恢复外部会话不是单纯的 transcript 还原——每个解析路径的工具都会随之移动。 - Harness home 现在保存着这台机器上每个项目的会话日志。它的增长不再受单个 checkout 约束,而本记录也没有引入任何保留策略。 diff --git a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.i18n.yaml deleted file mode 100644 index eb4893717e..0000000000 --- a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.i18n.yaml +++ /dev/null @@ -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 .agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md -2026-07-28-live-session-registry-and-dsh-ls.md: 02343c83ccee7b67e3b3e4c72de842415d4a9f6e -2026-07-28-live-session-registry-and-dsh-ls.zh.md: 722ac45f2eb07256f196d2828b8969ae9f4965b8 diff --git a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md deleted file mode 100644 index 02343c83cc..0000000000 --- a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md +++ /dev/null @@ -1,65 +0,0 @@ -# Agent Note: live-session registry and `dsh list-sessions` - -Status: implemented - -English | [中文](2026-07-28-live-session-registry-and-dsh-ls.zh.md) - -## Problem - -Nothing could answer "which dsh sessions am I running right now". A user with sessions across several projects had no way to enumerate them, and no way to recover the id needed for `--resume` except the exit line of the terminal that printed it. Session persistence records every session that ever existed, so it cannot answer the question: it has no notion of liveness, and no `process.pid` appeared anywhere in the session, persistence, or storage packages. - -## Decision - -`dsh list-sessions` (alias `dsh ps`) lists the sessions running right now — session id, pid, uptime, workspace, title — newest first, across every workspace, with `--json` for machines. Three packages back it, as a capability seam. - -[`dsh-session-registry`](../../../../packages/session-registry/session-registry/README.md) (`ctx.sessionRegistry`) is the seam: the abstract service contract and record vocabulary, so the medium can later move to a database without touching consumers. [`dsh-session-registry-file`](../../../../packages/session-registry/session-registry-file/README.md) implements it over one lock-guarded JSON file under the Harness home. [`dsh-session-registry-live`](../../../../packages/session-registry/session-registry-live/README.md) follows `session/created`, `session/disposed`, and `session/title` and keeps the registry in step. `apps/cli` mounts both on every launcher surface — the TUI, `dsh meta`, headless, and web — and `dsh list-sessions` mounts only the service, booting no agent tree. No surface label is recorded: a launcher's mode is not a property of the session, and the workspace column already distinguishes a `dsh meta` session from a project one. - -### Liveness is derived, never stored - -`list()` probes each record's pid with `kill(pid, 0)` and drops the dead ones, writing the pruned result back. A process killed without running its disposer leaves a record that the next read removes, so there is no daemon, no heartbeat, and no permanent phantom. A per-process `bootId` distinguishes a recycled pid, so deregistration cannot delete a namesake record from a different incarnation. `EPERM` counts as alive: a live session owned by another user must not be dropped. - -### Two independent concurrency layers - -The file is written by every dsh process and by several sessions inside one process, and the two cases need different mechanisms. - -Across processes, each read-modify-write holds a [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) advisory lock. Within one process, calls queue on an internal chain, because the advisory lock is tracked per process: overlapping same-process callers contend for its bounded retry budget rather than queueing, and past roughly a dozen concurrent calls that budget runs out and a registration rejects. Since publication is fire-and-forget, such a rejection silently drops a live session from the listing — the exact "listing that lies" failure this feature exists to avoid. Both layers are load-bearing and each is pinned by a test that fails without it. - -### Records carry their own title - -The title is the one mutable field, replaced through `retitle` as `session/title` events arrive. It lives in the record rather than being read from the session log because the log's location, format, and compression are per-deployment backend choices: the TUI writes project-local zstd-compressed JSONL, the web and headless surfaces write to a global root, a user profile overrides either, and SQLite has no per-session file at all. An independent reader cannot portably parse that, so `dsh list-sessions` opens no log and assumes no backend. - -### Subagents are invisible by construction - -Only top-level launcher surfaces mount the publisher. In-process subagents (`spawn`, `fork`) have no process of their own, and the out-of-process backends spawn `dsh-jsonrpc-agent` rather than this CLI. No filter flag is needed, and no subagent package changed. - -## Alternatives considered - -**One file per session under `~/.dsh/run/`.** No lock at all, since each process only writes and deletes its own file. Rejected in favour of the single file the user chose, which then made a real advisory lock mandatory rather than optional. - -**A domain over the `storage-json` backend.** The obvious reuse, and wrong: that backend documents "no cross-process write locking … last write wins" and names single-host-process as its assumption, and the [domain KV storage note](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md) puts multi-process explicitly out of scope. A registry written concurrently by every launcher is precisely that excluded case. Widening the backend's contract would have changed a shipped guarantee for one consumer; a separate package owns the multi-process medium instead. - -**Hand-rolled `O_EXCL` lock directory.** Rejected under the [dependencies-over-hand-rolling policy](../process/2026-07-26-dependencies-over-hand-rolling.md): stale-lock detection, retry backoff, and compromise handling are exactly the surface a maintained dependency should own. - -**Accept last-write-wins on the single file.** Cheapest to build, and it silently omits real running sessions when two start close together. A listing tool that lies is worse than no listing tool. - -**Read the title from the session log in `dsh list-sessions`.** Implemented first, then verified live: the shipped TUI writes `session.jsonl.zstd`, whose frame helpers are internal to the jsonl backend. Exporting them would have hard-coded one backend's file format into the CLI and still shown nothing for SQLite. - -**Register the web server itself with a placeholder session id.** `dsh web` owns no session — its sessions are created later by browser clients — so a server row would have put a fake id in a session table. Following session lifecycle instead makes browser sessions appear and disappear as they are opened, which also subsumed the TUI's launcher-side registration and deleted that separate path. - -**A `--here`/`--workspace` filter.** Dropped on request: the listing is always global, and narrowing is the user's `grep`. - -## Consequences - -The registry is an observability aid, so every write is best-effort: a registry fault warns and never fails a working agent session. The cost is that a listing can lag reality by one failed write, healed by the next. - -Title mirroring costs one locked read-modify-write per revision, so an aggressive retitling cadence pays that write each time. - -`bootId` bounds pid reuse only for records this process wrote. A foreign record whose pid the operating system has reassigned to an unrelated live process is reported alive until its owner removes it — accepted because the portable alternative, reading real process start times, is `/proc`-only. - -Liveness is pid existence, not health: a hung process still lists as running. The registry deliberately makes no progress judgement. - -## Testing - -Unit coverage pins durable-format validation (torn text, foreign version, per-row damage that must not hide siblings), pid pruning against a genuinely reaped pid, `EPERM`-is-alive, incarnation-scoped deregistration, and `retitle` scoping. Both concurrency layers have a regression test verified to fail when its mechanism is removed: 8 real processes for the cross-process lock, 24 overlapping in-process calls for the chain. The publisher is tested over the real `SessionStore` rather than a hand-built emitter, because publication depends on the store's actual lifecycle dispatch. - -Verified live in tmux against the assembled application: two concurrent TUI sessions in different workspaces both listed, a title appeared after the first turn, clean exit deregistered, and `SIGKILL` left a stale record that the next `dsh list-sessions` pruned and durably rewrote. diff --git a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.zh.md b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.zh.md deleted file mode 100644 index 722ac45f2e..0000000000 --- a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.zh.md +++ /dev/null @@ -1,65 +0,0 @@ -# Agent Note: 活跃会话注册表与 `dsh list-sessions` - -Status: implemented - -[English](2026-07-28-live-session-registry-and-dsh-ls.md) | 中文 - -## 问题 - -没有任何东西能回答「我此刻正在运行哪些 dsh 会话」。会话散落在多个项目中的用户既无法枚举它们,也无法找回 `--resume` 所需的 id,唯一的来源是打印过它的那个终端的退出行。会话持久化记录了曾经存在过的每个会话,因此它答不了这个问题:它没有存活状态的概念,而且 session、persistence、storage 这几个包里任何位置都没有出现过 `process.pid`。 - -## 决策 - -`dsh list-sessions`(别名 `dsh ps`)列出此刻正在运行的会话(会话 id、pid、运行时长、工作区、标题),最新的排在最前,覆盖所有工作区,并提供面向机器的 `--json`。背后由三个包(package)以能力 seam 的形式支撑。 - -[`dsh-session-registry`](../../../../packages/session-registry/session-registry/README.md)(`ctx.sessionRegistry`)是 seam:抽象服务契约与记录词汇,使介质将来可以换成数据库而不触及消费方。[`dsh-session-registry-file`](../../../../packages/session-registry/session-registry-file/README.md) 在 Harness home 下的一个加锁保护的 JSON 文件上实现它。[`dsh-session-registry-live`](../../../../packages/session-registry/session-registry-live/README.md) 跟随 `session/created`、`session/disposed` 和 `session/title`,让注册表保持同步。`apps/cli` 在每个启动方接口(TUI、`dsh meta`、headless、web)上都挂载这两个包,而 `dsh list-sessions` 只挂载该服务,不启动任何 agent(智能体)树。不记录任何接口标签:启动方的模式并不是会话的属性,而工作区那一列已经能把 `dsh meta` 会话和项目会话区分开。 - -### 存活状态是推导出来的,绝不存储 - -`list()` 用 `kill(pid, 0)` 探测每条记录的 pid,剪除已消亡的记录,并把剪除后的结果写回。未运行 disposer(资源释放)就被杀掉的进程留下的记录,会被下一次读取移除,因此不需要 daemon,不需要心跳,也不会有永久残留的幽灵记录。每个进程独有的 `bootId` 用于区分被复用的 pid,因此注销不会删除属于另一个 incarnation 的同名记录。`EPERM` 算作存活:归属于另一个用户的存活会话绝不能被丢掉。 - -### 两层相互独立的并发机制 - -该文件既被每个 dsh 进程写入,也被同一进程内的多个会话写入,这两种情形需要不同的机制。 - -跨进程时,每次读-改-写都持有一个 [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) 咨询锁。进程内则由各次调用在内部链上排队,因为咨询锁是按进程跟踪的:同一进程中重叠的调用方会争抢它有界的重试预算,而不是排队等待;大约超过十几次并发调用后,该预算耗尽,某次注册就会被拒绝。由于发布采用 fire-and-forget 方式,这样一次拒绝会静默地把一个存活会话从列表中丢掉——而这正是本功能要避免的「列表说谎」故障。两层机制都是必需的,且各有一个测试固定它:移除该机制,对应测试就会失败。 - -### 记录自带标题 - -标题是唯一的可变字段,随 `session/title` 事件到达,通过 `retitle` 替换。它存放在记录里,而不是从会话日志读取,因为日志的位置、格式和压缩都是逐部署的后端选择:TUI 写入项目本地的 zstd 压缩 JSONL,web 与 headless 界面写入全局根目录,用户配置文件可以覆盖二者,而 SQLite 根本没有逐会话的文件。独立读取方无法以可移植的方式解析这些内容,因此 `dsh list-sessions` 不打开任何日志,也不假定任何后端。 - -### subagent 在设计上就不可见 - -只有顶层启动方接口才挂载发布方。进程内 subagent(`spawn`、`fork`)没有自己的进程,而进程外后端 spawn 的是 `dsh-jsonrpc-agent` 而不是本 CLI(命令行界面)。不需要任何过滤开关,也没有改动任何 subagent 包。 - -## 考虑过的替代方案 - -**在 `~/.dsh/run/` 下每个会话一个文件。** 完全不需要锁,因为每个进程只写入和删除自己的文件。不予采纳,改用用户选定的单文件方案,而这也使真正的咨询锁从可选变为必需。 - -**在 `storage-json` 后端之上做一个 domain。** 这是最显而易见的复用,但它是错的:该后端明确记载「无跨进程写锁……最后写入者胜出」,并把单一宿主进程列为自身前提,而[domain KV 存储 note](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)明确把多进程排除在范围之外。被每个启动方并发写入的注册表恰恰就是这个被排除的场景。放宽该后端的契约,等于为一个消费方改动一项已上线的保证;改由一个独立的包拥有这套多进程介质。 - -**手写 `O_EXCL` 锁目录。** 依据[优先使用依赖而非手写政策](../process/2026-07-26-dependencies-over-hand-rolling.md)不予采纳:陈旧锁检测、重试退避和受损处理,恰恰是应当由一个有人维护的依赖拥有的那部分工作。 - -**在单文件上接受最后写入者胜出。** 这是最省事的实现,但当两个会话相近时间启动时,它会静默漏掉真实运行中的会话。一个会说谎的列表工具比没有列表工具更糟。 - -**在 `dsh list-sessions` 中从会话日志读取标题。** 该方案先落地实现,随后经实机验证否决:上线的 TUI 写入 `session.jsonl.zstd`,其帧处理辅助函数是 jsonl 后端的内部实现。把它们导出,等于把某一个后端的文件格式硬编码进 CLI,而且对 SQLite 仍然什么都显示不出来。 - -**用占位会话 id 注册 web 服务器本身。** `dsh web` 不拥有任何会话(它的会话由浏览器客户端稍后创建),因此一行服务器记录会把一个假 id 放进会话表。改为跟随会话生命周期后,浏览器会话会随打开与关闭而出现和消失,这同时也涵盖了 TUI 启动方一侧的注册,并删除了那条独立路径。 - -**加一个 `--here`/`--workspace` 过滤开关。** 按要求放弃:列表始终是全局的,收窄范围交给用户自己的 `grep`。 - -## 后果 - -注册表是一项可观测性辅助设施,因此每次写入都是尽力而为:注册表故障只发出警告,绝不让正常工作的 agent 会话失败。代价是列表可能因一次失败的写入而落后于现实一步,并由下一次写入修复。 - -标题镜像每次修订都要付出一次加锁的读-改-写,因此改名节奏激进时,每次改名都要付出这一次写入。 - -`bootId` 只对本进程写入的记录约束 pid 复用。如果一条外来记录的 pid 已被操作系统重新分配给一个无关的存活进程,那么在其所有者移除它之前,该记录会被报告为存活——之所以接受,是因为可移植的替代方案(读取进程真实启动时间)仅在 `/proc` 上可用。 - -存活状态只表示 pid 存在,不表示健康:挂死的进程仍会被列为正在运行。注册表刻意不对进展作出判断。 - -## 测试 - -单元覆盖固定了持久格式校验(截断文本、外来版本、不得遮蔽同级记录的单条损坏)、针对真正已回收 pid 的剪除、`EPERM` 算存活、按 incarnation 限定范围的注销,以及 `retitle` 的作用范围。两层并发机制各有一个回归测试,且都已验证在移除对应机制后会失败:跨进程锁用 8 个真实进程,进程内链用 24 次重叠调用。发布方在真实的 `SessionStore` 上测试,而非手搭的事件发射器,因为发布依赖该 store 实际的生命周期派发。 - -已在 tmux 中针对组装后的应用实机验证:位于不同工作区的两个并发 TUI 会话都被列出,第一轮之后出现标题,正常退出完成注销,而 `SIGKILL` 留下的陈旧记录被下一次 `dsh list-sessions` 剪除并持久重写。 diff --git a/apps/cli/base.cordis.yml b/apps/cli/base.cordis.yml index 8ece88a40d..40bb3fe946 100644 --- a/apps/cli/base.cordis.yml +++ b/apps/cli/base.cordis.yml @@ -53,7 +53,7 @@ # The session store root is the launcher's policy, not a plugin's: `dsh` shares # one store under the Harness home across every cwd, so `/resume` and -# `dsh ps` span workspaces. Without a launcher the project-local fallback keeps +# `/resume` spans workspaces. Without a launcher the project-local fallback keeps # an embedder's sessions beside its project. - id: session-persistence-jsonl name: '@deepseek-ai/dsh-session-persistence-jsonl' diff --git a/docs/capability-seams.md b/docs/capability-seams.md index f4607ce0f2..a985ecd2ba 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -47,10 +47,6 @@ flowchart LR pkg_workspace["workspace"] svc_workspace["ctx.workspace
Workspace entity registry"] pkg_apiproxy["apiproxy"] - pkg_session_registry["session-registry"] - svc_sessionRegistry["ctx.sessionRegistry
Live-session registry"] - pkg_session_registry_file["session-registry-file"] - pkg_session_registry_live["session-registry-live"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] pkg_tool_session_query["tool-session-query"] @@ -200,8 +196,6 @@ flowchart LR pkg_session_query --> svc_sessionQuery pkg_session_query_sqlite --> svc_sessionQuery pkg_session_reference --> svc_sessionReferences - pkg_session_registry --> svc_sessionRegistry - pkg_session_registry_file --> svc_sessionRegistry pkg_session_telemetry --> svc_telemetry pkg_session_telemetry_otel --> svc_telemetry pkg_session_title --> svc_sessionTitle @@ -283,7 +277,6 @@ flowchart LR svc_sessionQuery --> pkg_session_reference svc_sessionQuery --> pkg_tool_session_query svc_sessionReferences --> pkg_tui - svc_sessionRegistry --> pkg_session_registry_live svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop svc_sessions --> pkg_cli_demo @@ -344,7 +337,6 @@ flowchart LR | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | | `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. | -| `ctx.sessionRegistry` | `seam` | [`session-registry`](../packages/session-registry/session-registry) | [`session-registry-file`](../packages/session-registry/session-registry-file) | [`session-registry-live`](../packages/session-registry/session-registry-live) | - | Seam contract for live-session records; the file backend owns the lock-guarded medium, liveness is derived from the recorded pid at read time, and the publisher mirrors lifecycle and title events for `dsh list-sessions`. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. | | `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | [`tui`](../packages/ui/tui) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session-title/session-title) | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 870c00e051..49e23c662e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1153,26 +1153,6 @@ export interface Config { Source: [`packages/context/session-reference/src/config.ts:11`](../packages/context/session-reference/src/config.ts) -## `@deepseek-ai/dsh-session-registry-file` - -```ts config-catalog -/** - * Plugin config as callers write it: `root` is required — a cwd fallback would - * scatter registries — while the lock tunables are optional because - * `static Config` supplies their defaults. - */ -export interface Config { - /** Directory holding the registry file; created `0o700` on demand. */ - root: string - /** Milliseconds after which a held lock is considered abandoned and reclaimed. */ - lockStaleMs?: number - /** Retries before a contended acquisition fails loud. */ - lockRetries?: number -} -``` - -Source: [`packages/session-registry/session-registry-file/src/index.ts:43`](../packages/session-registry/session-registry-file/src/index.ts) - ## `@deepseek-ai/dsh-session-telemetry-otel` Requires: `sessions` @@ -2194,7 +2174,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-session-projection` ([`packages/session-projection/session-projection/src/index.ts`](../packages/session-projection/session-projection/src/index.ts)) -- `@deepseek-ai/dsh-session-registry-live` — requires `sessions` · `sessionRegistry` ([`packages/session-registry/session-registry-live/src/index.ts`](../packages/session-registry/session-registry-live/src/index.ts)) - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts)) @@ -2217,7 +2196,6 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) - `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts)) -- `@deepseek-ai/dsh-session-registry` — abstract `SessionRegistry` ([`packages/session-registry/session-registry/src/index.ts`](../packages/session-registry/session-registry/src/index.ts)) - `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts)) - `@deepseek-ai/dsh-subprocess` — abstract `SubprocessService` ([`packages/subprocess/subprocess/src/index.ts`](../packages/subprocess/subprocess/src/index.ts)) - `@deepseek-ai/dsh-tasks` — abstract `TaskService` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index f576af1243..59a5405bfb 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1464,44 +1464,6 @@ Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-s Source: [`packages/context/session-reference/src/index.ts:70`](../../packages/context/session-reference/src/index.ts) -## `ctx.sessionRegistry` — `SessionRegistry` (abstract seam) - -Cross-process live-session registry. Reads prune dead records, so every returned record's process existed at observation time. Backends serialize mutations against concurrent registrars — other processes and overlapping calls in this one — so records are never lost to a torn read-modify-write. - -```ts cordis-catalog -/** - * Publish this process's record, replacing any stale record for the same - * session id, and prune records whose process is gone. - * @param registration - the session, surface, and workspace to publish. - * @returns the effect disposer that removes this record again; awaiting it - * waits for the removal to reach durability. - */ -abstract register(registration: SessionRegistration): Promise<() => Promise> - -/** - * Replace the recorded title of a session this process registered. - * - * Titles arrive after registration and can be revised, so this is the one - * mutable field. Only a record matching this process and incarnation is - * touched, leaving a same-id record owned by another process alone. An unknown - * session id is a no-op rather than an error: a title can resolve after the - * session's record has already been removed. - * @param sessionId - the session whose recorded title changes. - * @param title - the new title text. - */ -abstract retitle(sessionId: SessionId, title: string): Promise - -/** - * List live sessions, pruning records whose process no longer exists. - * @returns one record per live registered session, newest registration last. - */ -abstract list(): Promise -``` - -Types: [SessionId](../core-data-structures/core.md) - -Source: [`packages/session-registry/session-registry/src/index.ts:44`](../../packages/session-registry/session-registry/src/index.ts) - ## `ctx.sessions` — `SessionStore` In-memory session store (`ctx.sessions`). diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 13448e2c5b..141f770748 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -31,9 +31,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | diff --git a/knip.json b/knip.json index e40f343dff..b79a4d8977 100644 --- a/knip.json +++ b/knip.json @@ -288,7 +288,6 @@ "tests/**/*.ts" ] }, - "packages/session-registry/session-registry-file": { "entry": [ "tests/**/*.spec.ts", "tests/fixtures/register-once.ts" diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index a27c126b75..d238897cf3 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -688,24 +688,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, - { - key: 'sessionRegistry', - summary: 'Cross-process live-session registry.', - methods: [ - { - signature: 'abstract register(registration: SessionRegistration): Promise<() => Promise>', - jsDoc: '/**\n * Publish this process\'s record, replacing any stale record for the same\n * session id, and prune records whose process is gone.\n * @param registration - the session, surface, and workspace to publish.\n * @returns the effect disposer that removes this record again; awaiting it\n * waits for the removal to reach durability.\n */', - }, - { - signature: 'abstract retitle(sessionId: SessionId, title: string): Promise', - jsDoc: '/**\n * Replace the recorded title of a session this process registered.\n *\n * Titles arrive after registration and can be revised, so this is the one\n * mutable field. Only a record matching this process and incarnation is\n * touched, leaving a same-id record owned by another process alone. An unknown\n * session id is a no-op rather than an error: a title can resolve after the\n * session\'s record has already been removed.\n * @param sessionId - the session whose recorded title changes.\n * @param title - the new title text.\n */', - }, - { - signature: 'abstract list(): Promise', - jsDoc: '/**\n * List live sessions, pruning records whose process no longer exists.\n * @returns one record per live registered session, newest registration last.\n */', - }, - ], - }, { key: 'sessions', summary: 'In-memory session store (`ctx.sessions`).', @@ -2270,12 +2252,8 @@ export const TYPE_API: readonly TypeApiEntry[] = [ declaration: 'export interface SessionReferenceInput {\n sessionId: SessionId;\n label?: string;\n}', }, { - name: 'SessionRegistration', - declaration: 'export interface SessionRegistration {\n sessionId: SessionId;\n cwd: string;\n title?: string;\n}', }, { - name: 'SessionRegistryRecord', - declaration: 'export interface SessionRegistryRecord {\n readonly sessionId: SessionId;\n readonly pid: number;\n readonly cwd: string;\n readonly startedAt: number;\n readonly bootId: BootId;\n readonly title?: string;\n}', }, { name: 'SessionResultFilter', diff --git a/packages/session-registry/README.i18n.yaml b/packages/session-registry/README.i18n.yaml deleted file mode 100644 index 6eb22cf334..0000000000 --- a/packages/session-registry/README.i18n.yaml +++ /dev/null @@ -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/session-registry/README.md -README.md: c79caccd05d6cbdda0663dd897490fb6004b8250 -README.zh.md: c3fff3bc6b0e0417dd291d129d7fb1001b50258f diff --git a/packages/session-registry/README.md b/packages/session-registry/README.md deleted file mode 100644 index c79caccd05..0000000000 --- a/packages/session-registry/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# session-registry/ — live-session registry family - -English | [中文](README.zh.md) - -Which sessions are running right now, readable from a different process. `dsh list-sessions` is the consumer. - -| Package | Role | ctx key | -|---|---|---| -| [`session-registry/`](session-registry/README.md) | The seam: abstract registry service contract and record vocabulary | `ctx.sessionRegistry` | -| [`session-registry-file/`](session-registry-file/README.md) | Backend: one lock-guarded JSON file, pid-derived liveness | — | -| [`session-registry-live/`](session-registry-live/README.md) | Publisher: follows session lifecycle and title events, keeping the registry in step | — | - -The split follows the three-package capability-seam convention: the seam answers "what is live" for a short-lived reader that mounts nothing else, the file backend owns today's medium and can be replaced by a database without touching consumers, and the publisher needs the session store and runs inside a full agent composition. Liveness is derived from the recorded pid at read time rather than stored, so a killed process leaves nothing to clean up. Records carry their own title because log location, format, and compression are per-deployment backend choices an independent reader cannot portably parse. - -This family is independent of session persistence: it records which processes hold which sessions, never conversation content, and a session that is never persisted still lists. diff --git a/packages/session-registry/README.zh.md b/packages/session-registry/README.zh.md deleted file mode 100644 index c3fff3bc6b..0000000000 --- a/packages/session-registry/README.zh.md +++ /dev/null @@ -1,15 +0,0 @@ -# session-registry/:活跃会话注册表家族 - -[English](README.md) | 中文 - -当前正在运行哪些会话,可以从另一个进程读取。消费方是 `dsh list-sessions`。 - -| 包 | 职责 | ctx 键 | -|---|---|---| -| [`session-registry/`](session-registry/README.md) | seam:抽象注册表服务契约与记录词汇 | `ctx.sessionRegistry` | -| [`session-registry-file/`](session-registry-file/README.md) | 后端:单个加锁保护的 JSON 文件、由 pid 推导的存活状态 | — | -| [`session-registry-live/`](session-registry-live/README.md) | 发布方:跟随会话生命周期与标题事件,让注册表保持同步 | — | - -这样拆分遵循由三个包构成的能力 seam 惯例:seam 要回答「哪些会话是活跃的」,供一个不挂载其他任何东西的短生命周期读取方使用;文件后端拥有今天的介质,将来可以换成数据库而不触及消费方;发布方需要会话存储,运行在完整的 agent(智能体)组合体内。存活状态在读取时由记录的 pid 推导,而不是存下来,因此进程被杀掉后不留下任何需要清理的东西。记录自带标题,因为日志位置、格式和压缩都是各部署自行选择的后端方案,独立的读取方无法以可移植的方式解析。 - -这个家族与会话持久化相互独立:它只记录哪些进程持有哪些会话,绝不记录对话内容;从未被持久化的会话同样能被列出。 diff --git a/packages/session-registry/session-registry-file/README.i18n.yaml b/packages/session-registry/session-registry-file/README.i18n.yaml deleted file mode 100644 index d08b25d86f..0000000000 --- a/packages/session-registry/session-registry-file/README.i18n.yaml +++ /dev/null @@ -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/session-registry/session-registry-file/README.md -README.md: b6f29a459e5f7f6484aed9f4968d54f969ca6e73 -README.zh.md: 35861466ed5fb826ee118c506943c3a08024a827 diff --git a/packages/session-registry/session-registry-file/README.md b/packages/session-registry/session-registry-file/README.md deleted file mode 100644 index b6f29a459e..0000000000 --- a/packages/session-registry/session-registry-file/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# @deepseek-ai/dsh-session-registry-file - -English | [中文](README.zh.md) - -File-backed implementation of the [live-session registry seam](../session-registry/README.md): one lock-guarded JSON file under the Harness home is the whole medium. Mounting it publishes `ctx.sessionRegistry`; `file` exposes the absolute registry path (`/sessions.json`). - -## Liveness and crash safety - -Liveness is derived at read time from the recorded pid via `kill(pid, 0)`: `ESRCH` is dead, `EPERM` is alive under another user, and any other errno propagates rather than being read as an answer. A process killed without running its disposer therefore leaves a record that the next `list()` prunes and rewrites — no daemon, no heartbeat, and no permanent phantom. `bootId` distinguishes a recycled pid, so deregistration cannot delete a namesake record belonging to a different incarnation. - -## Concurrency - -Both layers are required and neither substitutes for the other. - -- **Across processes**, each read-modify-write cycle holds a [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) advisory lock. Unlocked whole-file republication loses records under concurrent launchers, which is why the storage-hub JSON backend — documented last-write-wins, single-host-process — cannot serve this medium. -- **Within one process**, calls queue on an internal chain. The advisory lock is tracked per process, so overlapping same-process callers contend for its bounded retry budget instead of queueing; past roughly a dozen concurrent calls that budget runs out and a registration rejects. Callers publish fire-and-forget, so such a rejection would silently drop a live session from the listing. - -Writes are temp-file plus atomic `rename` (no fsync: a listing lost to a crash is rebuilt by the next process's read, so crash durability buys nothing here), under a `0o700` root with a `0o600` file. - -## Durable format - -`sessions.json` carries a `version` stamp pinned at `0` under the pre-release stance: a differing version is rejected rather than migrated. Reads validate every field because the medium is shared and user-visible. An individually unusable row is dropped while its siblings survive, and unparsable text or a foreign version reads as empty — one malformed record written by another harness version must not hide every other live session. Any of these marks the medium damaged, so the next write republishes and heals it. - -## Config - -| Key | Type | Default | Meaning | -| --- | --- | --- | --- | -| `root` | string | required — no default (a cwd fallback would scatter registries) | Directory holding `sessions.json`; created `0o700` on demand | -| `lockStaleMs` | natural | `10000` | Milliseconds after which a held lock is treated as abandoned and reclaimed | -| `lockRetries` | natural | `10` | Retries before a contended acquisition fails loud | - -## Model Experience - -None, as this package registers no tools, injects no prompts, and appends no session events; it stores host-side process records for the CLI listing surface only. - -#### KV Cache effect - -Independent of live requests: the registry never touches a request prefix, so nothing here can invalidate provider cache reuse. - -## Known Limitations and Deferred Work - -- **A reused pid within the stale window is trusted** — `bootId` distinguishes incarnations of records this process wrote, but a foreign record whose pid the operating system has since reassigned to an unrelated live process is reported alive until its owner removes it. diff --git a/packages/session-registry/session-registry-file/README.zh.md b/packages/session-registry/session-registry-file/README.zh.md deleted file mode 100644 index 35861466ed..0000000000 --- a/packages/session-registry/session-registry-file/README.zh.md +++ /dev/null @@ -1,42 +0,0 @@ -# @deepseek-ai/dsh-session-registry-file - -[English](README.md) | 中文 - -[存活会话注册表 seam](../session-registry/README.md) 的文件后端实现:整套介质就是 Harness home 下的一个加锁保护的 JSON 文件。挂载它即发布 `ctx.sessionRegistry`;`file` 暴露注册表文件的绝对路径(`/sessions.json`)。 - -## 存活状态与崩溃安全 - -存活状态在读取时由记录的 pid 经 `kill(pid, 0)` 推导:`ESRCH` 表示已消亡,`EPERM` 表示存活于另一个用户之下,其他任何 errno 都向外抛出,而不会被当成一个答案来解读。因此,未运行 disposer 就被杀掉的进程留下的记录,会被下一次 `list()` 剪除并重写——不需要 daemon,不需要心跳,也不会有永久残留的幽灵记录。`bootId` 用于区分被复用的 pid,因此注销不会删除属于另一个 incarnation 的同名记录。 - -## 并发 - -两层机制都是必需的,任何一层都无法替代另一层。 - -- **跨进程**:每个读改写周期都持有 [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) 咨询锁。无锁的全文件重发布会在并发启动器下丢失记录,这正是 storage-hub JSON 后端(文档声明 last-write-wins、单宿主进程)无法承担该介质的原因。 -- **进程内**:调用在内部链上排队。咨询锁按进程跟踪,因此同进程的重叠调用者会争用其有限的重试预算而非排队;并发调用超过十来个时预算耗尽,注册会被拒绝。调用方以 fire-and-forget 方式发布,这样的拒绝会静默地把一个存活会话从列表中丢掉。 - -写入采用临时文件加原子 `rename`(不做 fsync:崩溃丢失的列表会被下一个进程的读取重建,崩溃持久性在这里没有收益),根目录 `0o700`,文件 `0o600`。 - -## 持久化格式 - -`sessions.json` 携带一个 `version` 戳,在预发布立场下固定为 `0`:版本不同将被拒绝而非迁移。由于介质是共享且用户可见的,读取会校验每个字段。单条不可用的行会被丢弃而其同伴保留;无法解析的文本或异版本文件读作空——另一个 harness 版本写入的一条损坏记录,不得隐藏所有其他存活会话。上述任一情况都会把介质标记为受损,下一次写入将重新发布并修复它。 - -## 配置 - -| 键 | 类型 | 默认值 | 含义 | -| --- | --- | --- | --- | -| `root` | string | 必填——无默认值(回退到 cwd 会使注册表散落各处) | 存放 `sessions.json` 的目录;按需以 `0o700` 创建 | -| `lockStaleMs` | natural | `10000` | 持有的锁超过该毫秒数即视为被遗弃并被回收 | -| `lockRetries` | natural | `10` | 锁争用时在明确失败前的重试次数 | - -## 模型体验 - -无。本包不注册工具、不注入提示词、不追加会话事件;它只为 CLI 列表界面存储宿主侧进程记录。 - -#### KV 缓存影响 - -与在途请求无关:注册表从不触碰请求前缀,因此这里不会使提供方缓存复用失效。 - -## 已知限制与后续工作 - -- **陈旧窗口内被复用的 pid 会被信任**——`bootId` 能区分本进程所写记录的 incarnation,但外来记录的 pid 若已被操作系统重新分配给无关的存活进程,在其属主移除之前会一直被报告为存活。 diff --git a/packages/session-registry/session-registry-file/package.json b/packages/session-registry/session-registry-file/package.json deleted file mode 100644 index 321a59902d..0000000000 --- a/packages/session-registry/session-registry-file/package.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-session-registry-file", - "description": "Lock-guarded JSON-file backend for the dsh live-session registry seam", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json", - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - } - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" - ], - "license": "BSD-3-Clause", - "dependencies": { - "proper-lockfile": "^4.1.2", - "schemastery": "^3.15.0" - }, - "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-registry": "^0.0.1", - "cordis": "^4.0.0-rc.6" - }, - "devDependencies": { - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-registry": "workspace:^", - "@types/proper-lockfile": "^4.1.4", - "cordis": "^4.0.0-rc.6" - } -} diff --git a/packages/session-registry/session-registry-file/src/file.ts b/packages/session-registry/session-registry-file/src/file.ts deleted file mode 100644 index 0c9a5775fb..0000000000 --- a/packages/session-registry/session-registry-file/src/file.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Registry file format: the durable boundary between independent `dsh` - * processes. Every field is validated on read because the medium is shared, - * user-visible, and writable by other harness versions — a foreign or truncated - * file must not crash `dsh list-sessions` into an empty listing that hides live sessions. - * @module @deepseek-ai/dsh-session-registry-file/file - */ - -import { SessionId } from '@deepseek-ai/dsh-session' -import { BootId, type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry' - -/** - * On-disk format version. Pinned at `0` under the pre-release stance: a - * differing version is rejected rather than migrated, matching every other - * harness backend. - */ -export const SESSION_REGISTRY_FORMAT_VERSION = 0 - -/** The complete registry file: a version stamp plus the live records. */ -export interface RegistryFileContents { - /** Format stamp, always {@link SESSION_REGISTRY_FORMAT_VERSION} when written. */ - readonly version: number - /** One record per registered process, in no significant order. */ - readonly records: readonly SessionRegistryRecord[] -} - -/** An empty registry: the value a missing file reads as. */ -export const EMPTY_REGISTRY: RegistryFileContents = { version: SESSION_REGISTRY_FORMAT_VERSION, records: [] } - -/** Narrow an unknown JSON value to a record shape, or reject it as unusable. */ -function parseRecord(value: unknown): SessionRegistryRecord | undefined { - if (typeof value !== 'object' || value === null) return undefined - const row = value as Record - const { sessionId, pid, cwd, startedAt, bootId } = row - if (typeof sessionId !== 'string' || sessionId === '') return undefined - // A non-integer or non-positive pid cannot be probed for liveness. - if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) return undefined - if (typeof cwd !== 'string' || cwd === '') return undefined - if (typeof startedAt !== 'number' || !Number.isSafeInteger(startedAt) || startedAt < 0) return undefined - if (typeof bootId !== 'string' || bootId === '') return undefined - // An absent title is legal (a fresh session has none); a present but - // non-string one is a damaged row rather than a missing optional field. - const { title } = row - if (title !== undefined && typeof title !== 'string') return undefined - return { - sessionId: SessionId(sessionId), - pid, - cwd, - startedAt, - bootId: BootId(bootId), - ...title !== undefined && { title }, - } -} - -/** - * Parse registry file text into records, dropping individually unusable rows. - * - * A row that cannot be interpreted is dropped rather than rejected wholesale: - * one malformed record written by a different harness version must not hide - * every other live session. Unparsable text and a version mismatch yield an - * empty registry for the same reason — the caller republishes the whole file, so - * the next write heals the medium. - * @param text - the raw file contents. - * @returns the records that parsed, and whether the text was fully understood. - */ -export function parseRegistry(text: string): { records: SessionRegistryRecord[]; intact: boolean } { - let parsed: unknown - try { - parsed = JSON.parse(text) - } catch { - // Swallows only SyntaxError from this one JSON.parse: a torn or foreign - // file heals on the next write, and nothing else can reach this catch. - return { records: [], intact: false } - } - if (typeof parsed !== 'object' || parsed === null) return { records: [], intact: false } - const file = parsed as Record - if (file.version !== SESSION_REGISTRY_FORMAT_VERSION) return { records: [], intact: false } - if (!Array.isArray(file.records)) return { records: [], intact: false } - const records: SessionRegistryRecord[] = [] - let intact = true - for (const row of file.records) { - const record = parseRecord(row) - if (record === undefined) intact = false - else records.push(record) - } - return { records, intact } -} - -/** - * Serialize records as registry file text. - * @param records - the live records to publish. - * @returns pretty-printed JSON with a trailing newline, for a legible medium. - */ -export function serializeRegistry(records: readonly SessionRegistryRecord[]): string { - const file: RegistryFileContents = { version: SESSION_REGISTRY_FORMAT_VERSION, records } - return `${JSON.stringify(file, undefined, 2)}\n` -} diff --git a/packages/session-registry/session-registry-file/src/index.ts b/packages/session-registry/session-registry-file/src/index.ts deleted file mode 100644 index 41e080b79a..0000000000 --- a/packages/session-registry/session-registry-file/src/index.ts +++ /dev/null @@ -1,233 +0,0 @@ -/** - * File-backed live-session registry: one lock-guarded JSON file under the - * Harness home implements the `@deepseek-ai/dsh-session-registry` seam. Every - * operation is a read-modify-write under an advisory lock, because concurrent - * launchers write the same file — the storage-hub JSON backend documents - * last-write-wins for exactly this case and cannot be reused. Liveness is - * derived at read time from the recorded pid. - * @module @deepseek-ai/dsh-session-registry-file - */ - -import { randomUUID } from 'node:crypto' -import { mkdir, readFile, rename, writeFile, open } from 'node:fs/promises' -import { dirname, join } from 'node:path' -import type { Context } from 'cordis' -import lockfile from 'proper-lockfile' -import z from 'schemastery' -import type { SessionId } from '@deepseek-ai/dsh-session' -import { - SessionRegistry, BootId, - type SessionRegistration, type SessionRegistryRecord, -} from '@deepseek-ai/dsh-session-registry' -import { EMPTY_REGISTRY, parseRegistry, serializeRegistry } from './file.ts' -import { isPidAlive } from './liveness.ts' - -export { SESSION_REGISTRY_FORMAT_VERSION, parseRegistry, serializeRegistry } from './file.ts' -export type { RegistryFileContents } from './file.ts' -export { isPidAlive } from './liveness.ts' - -/** The file name holding the registry, relative to {@link Config.root}. */ -export const REGISTRY_FILE_NAME = 'sessions.json' - -/** Default lock staleness threshold; a held lock older than this is reclaimed. */ -const DEFAULT_LOCK_STALE_MS = 10_000 - -/** Default retry budget for a contended lock acquisition. */ -const DEFAULT_LOCK_RETRIES = 10 - -/** - * Plugin config as callers write it: `root` is required — a cwd fallback would - * scatter registries — while the lock tunables are optional because - * `static Config` supplies their defaults. - */ -export interface Config { - /** Directory holding the registry file; created `0o700` on demand. */ - root: string - /** Milliseconds after which a held lock is considered abandoned and reclaimed. */ - lockStaleMs?: number - /** Retries before a contended acquisition fails loud. */ - lockRetries?: number -} - -/** The file-backed {@link SessionRegistry} implementation. */ -export class SessionRegistryFile extends SessionRegistry { - static Config: z = z.object({ - root: z.string().required(), - lockStaleMs: z.natural().default(DEFAULT_LOCK_STALE_MS), - lockRetries: z.natural().default(DEFAULT_LOCK_RETRIES), - }) - - /** Absolute path of the registry file this service reads and writes. */ - readonly file: string - - /** Directory holding {@link file}, created `0o700` on demand. */ - private readonly root: string - - /** Tail of the in-process serialization chain; see {@link mutate}. */ - private chain: Promise = Promise.resolve() - - /** Resolved lock staleness threshold in milliseconds, fixed at construction. */ - private readonly stale: number - - /** Resolved contended-acquisition retry budget, fixed at construction. */ - private readonly retries: number - - constructor(ctx: Context, config: Config) { - super(ctx, BootId(randomUUID())) - this.root = config.root - this.file = join(this.root, REGISTRY_FILE_NAME) - // Resolve the optional tunables here, once: `static Config` supplies these - // same defaults for a Loader mount, and a direct programmatic mount that - // omits them gets them too rather than an undefined lock option. - this.stale = config.lockStaleMs ?? DEFAULT_LOCK_STALE_MS - this.retries = config.lockRetries ?? DEFAULT_LOCK_RETRIES - } - - /** @inheritdoc */ - async register(registration: SessionRegistration): Promise<() => Promise> { - const record: SessionRegistryRecord = { - sessionId: registration.sessionId, - pid: process.pid, - cwd: registration.cwd, - startedAt: Date.now(), - bootId: this.bootId, - ...registration.title !== undefined && { title: registration.title }, - } - await this.mutate(records => [ - ...records.filter(other => other.sessionId !== record.sessionId), - record, - ]) - // The disposer is awaited by Cordis teardown, so the record is durably gone - // before disposal completes rather than racing process exit. A failure here - // is reported, not thrown: the record is already pid-prunable, and an - // unwinding teardown must not be turned into a rejection. - return this.ctx.effect(() => async () => { - try { - await this.mutate(records => records.filter(other => !this.isSelf(other, record))) - } catch (error) { - this.ctx.logger.warn('failed to deregister %s: %s', record.sessionId, String(error)) - } - }) - } - - /** @inheritdoc */ - async retitle(sessionId: SessionId, title: string): Promise { - await this.mutate(records => records.map(record => - record.sessionId === sessionId && record.pid === process.pid && record.bootId === this.bootId - ? { ...record, title } - : record)) - } - - /** @inheritdoc */ - async list(): Promise { - // Pruning is a write, so the read path takes the same lock: a listing that - // observed a half-written file could omit a live session. - return this.mutate(records => [...records]) - } - - /** True when a stored record is this exact registration (pid AND incarnation). */ - private isSelf(candidate: SessionRegistryRecord, self: SessionRegistryRecord): boolean { - return candidate.sessionId === self.sessionId - && candidate.pid === self.pid - && candidate.bootId === self.bootId - } - - /** - * Serialize one read-modify-write cycle against every other cycle in THIS - * process, then run it under the cross-process lock. - * - * Both layers are required and neither substitutes for the other. The advisory - * lock excludes other processes but is tracked per process, so it rejects a - * same-process concurrent acquisition outright (`ELOCKED`) instead of queueing - * — and a composition that creates several sessions at once really does - * overlap these calls. This chain gives those callers a queue; the lock gives - * independent processes exclusion. - */ - private mutate( - change: (records: readonly SessionRegistryRecord[]) => SessionRegistryRecord[], - ): Promise { - // Failures must not poison the chain for later callers, so the tail only - // tracks settlement, never the rejection itself. - const result = this.chain.then(() => this.mutateExclusively(change)) - this.chain = result.then(() => undefined, () => undefined) - return result - } - - /** - * Run one locked read-modify-write cycle: read, prune dead records, apply - * `change`, and republish when the result differs from what was stored. - */ - private async mutateExclusively( - change: (records: readonly SessionRegistryRecord[]) => SessionRegistryRecord[], - ): Promise { - await mkdir(this.root, { recursive: true, mode: 0o700 }) - // proper-lockfile needs the target to exist before it can guard it; an - // exclusive create loses harmlessly to a concurrent launcher doing the same. - await this.ensureFile() - const release = await lockfile.lock(this.file, { - stale: this.stale, - retries: { retries: this.retries, minTimeout: 20, maxTimeout: 500 }, - }) - try { - const before = await this.read() - const live = before.records.filter(record => isPidAlive(record.pid)) - const next = change(live) - // Republish when a record changed or the medium itself was damaged, so a - // foreign or torn file heals instead of being re-parsed on every read. - if (!before.intact || !sameRecords(before.records, next)) await this.write(next) - return next - } finally { - await release() - } - } - - /** Create the registry file if absent, without disturbing existing content. */ - private async ensureFile(): Promise { - try { - const handle = await open(this.file, 'wx', 0o600) - try { - await handle.writeFile(serializeRegistry(EMPTY_REGISTRY.records)) - } finally { - await handle.close() - } - } catch (error) { - // Swallows only EEXIST: another launcher created the file first, which is - // the intended outcome. Every other errno propagates. - /* v8 ignore next -- a non-EEXIST create failure needs a permission or IO fault on a root this cycle just created 0o700. */ - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error - } - } - - /** Read and parse the registry file; a missing file reads as empty. */ - private async read(): Promise<{ records: SessionRegistryRecord[]; intact: boolean }> { - // The caller holds the lock, and acquiring it requires the file to exist, so - // a read failure here is real corruption rather than an absent registry and - // propagates: a swallowed error would report "no live sessions" for a medium - // that could not be read. - return parseRegistry(await readFile(this.file, 'utf8')) - } - - /** Publish the complete record set via temp-write plus atomic rename. */ - private async write(records: readonly SessionRegistryRecord[]): Promise { - const temp = join(dirname(this.file), `.${REGISTRY_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`) - await writeFile(temp, serializeRegistry(records), { mode: 0o600 }) - await rename(temp, this.file) - } -} - -/** Compare record lists by identity fields, to decide whether a write is needed. */ -function sameRecords(left: readonly SessionRegistryRecord[], right: readonly SessionRegistryRecord[]): boolean { - if (left.length !== right.length) return false - return left.every((record, index) => { - const other = right[index] - return other !== undefined - && record.sessionId === other.sessionId - && record.pid === other.pid - && record.bootId === other.bootId - && record.cwd === other.cwd - && record.startedAt === other.startedAt - && record.title === other.title - }) -} - -export default SessionRegistryFile diff --git a/packages/session-registry/session-registry-file/src/invariant.ts b/packages/session-registry/session-registry-file/src/invariant.ts deleted file mode 100644 index 9fd175abc4..0000000000 --- a/packages/session-registry/session-registry-file/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-session-registry-file`. - * @module @deepseek-ai/dsh-session-registry-file/invariant - */ - -import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-session-registry-file' - -/** Cordis companion plugin name. */ -export const name = 'session-registry-file-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the relations a reader must trust (unique live session - * ids, attributable pids) are contract-level and validated by the seam's - * companion around the authoritative `list()`, whatever backend serves it. The - * file medium's own correctness — locking, atomic republication, and - * foreign-row rejection — requires cross-process round-trip tests, not a - * continuously observable in-process relation. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/session-registry/session-registry-file/src/liveness.ts b/packages/session-registry/session-registry-file/src/liveness.ts deleted file mode 100644 index ff7421edd9..0000000000 --- a/packages/session-registry/session-registry-file/src/liveness.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Process-liveness probe for stored registry records. - * @module @deepseek-ai/dsh-session-registry-file/liveness - */ - -/** - * Signal-0 probe: report whether a pid currently exists. - * - * `kill(pid, 0)` sends no signal and only tests existence. `ESRCH` means no such - * process. `EPERM` means the process exists but is owned by another user, which - * is still alive — reporting it dead would drop a live record. Any other errno - * is unexpected and propagates rather than being read as a liveness answer. - * @param pid - the operating-system process id to probe. - * @param kill - signal sender, defaulting to `process.kill`; injected by tests. - * @returns whether a process with this pid exists. - */ -export function isPidAlive( - pid: number, - kill: (pid: number, signal: number) => void = process.kill.bind(process), -): boolean { - try { - kill(pid, 0) - return true - } catch (error) { - const code = (error as NodeJS.ErrnoException).code - if (code === 'ESRCH') return false - if (code === 'EPERM') return true - throw error - } -} diff --git a/packages/session-registry/session-registry-file/tests/fixtures/register-once.ts b/packages/session-registry/session-registry-file/tests/fixtures/register-once.ts deleted file mode 100644 index 56e05d11fd..0000000000 --- a/packages/session-registry/session-registry-file/tests/fixtures/register-once.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Concurrency-test driver: register one session in a real separate process, - * report readiness on stdout, then stay alive until the parent closes stdin. - * - * Staying alive is load-bearing. The registry prunes records whose process is - * gone, so a driver that exited after writing would be pruned by the next - * writer — the test would then measure pruning instead of the concurrent - * read-modify-write it exists to cover. Argv: ` `. - */ - -import { Context } from 'cordis' -import { SessionId } from '@deepseek-ai/dsh-session' -import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file' - -const [root, sessionId] = process.argv.slice(2) -if (root === undefined || sessionId === undefined) throw new Error('usage: register-once ') - -const ctx = new Context() -await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 60 }) -await ctx.sessionRegistry.register({ sessionId: SessionId(sessionId), cwd: process.cwd() }) -process.stdout.write('registered\n') - -// Hold the process open so its record stays live; the parent ends the run by -// closing stdin, and never disposes the fiber, so no deregistration races the -// parent's read. -process.stdin.resume() -process.stdin.on('end', () => { process.exit(0) }) diff --git a/packages/session-registry/session-registry-file/tests/session-registry-file.spec.ts b/packages/session-registry/session-registry-file/tests/session-registry-file.spec.ts deleted file mode 100644 index e48bd46385..0000000000 --- a/packages/session-registry/session-registry-file/tests/session-registry-file.spec.ts +++ /dev/null @@ -1,382 +0,0 @@ -/** - * Tests for the cross-process live-session registry: records survive a - * round-trip, dead pids are pruned, a recycled pid cannot resurrect a foreign - * record, the file format rejects foreign and torn media without hiding live - * sessions, disposal deregisters, and concurrent registrations from independent - * processes all survive (the failure the advisory lock exists to prevent). - */ - -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' -import { execFile, spawn } from 'node:child_process' -import { tmpdir } from 'node:os' -import { fileURLToPath } from 'node:url' -import { join } from 'node:path' -import { promisify } from 'node:util' -import { SessionId } from '@deepseek-ai/dsh-session' -import { BootId } from '@deepseek-ai/dsh-session-registry' -import SessionRegistryFile, { - REGISTRY_FILE_NAME, - SESSION_REGISTRY_FORMAT_VERSION, - isPidAlive, - parseRegistry, - serializeRegistry, -} from '@deepseek-ai/dsh-session-registry-file' - -const run = promisify(execFile) - -let root: string - -beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'dsh-session-registry-test-')) -}) -afterEach(() => { - rmSync(root, { recursive: true, force: true }) -}) - -/** Mount the service on a fresh Cordis fiber, returning it with its context. */ -async function service(): Promise<{ ctx: Context; registry: SessionRegistryFile }> { - const ctx = new Context() - await ctx.plugin(SessionRegistryFile, { root }) - return { ctx, registry: ctx.sessionRegistry as SessionRegistryFile } -} - -const file = (): string => join(root, REGISTRY_FILE_NAME) - -describe('config resolution', () => { - it('applies the shipped lock defaults when a caller omits them', async () => { - // `ctx.plugin` runs the schema, which fills these in, so the constructor's - // own resolution is reachable only by constructing the service directly — - // the path a programmatic embedder takes. - const ctx = new Context() - const service = new SessionRegistryFile(ctx, { root }) - await service.register({ sessionId: SessionId('defaulted'), cwd: '/w' }) - expect((await service.list()).map(record => record.sessionId)).toEqual(['defaulted']) - await ctx.fiber.dispose() - }) - - it('honors explicitly configured lock tunables', async () => { - const ctx = new Context() - await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 5_000, lockRetries: 3 }) - await ctx.sessionRegistry.register({ sessionId: SessionId('tuned'), cwd: '/w' }) - expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['tuned']) - await ctx.fiber.dispose() - }) -}) - -describe('register and list', () => { - it('publishes a record readable by an independent service instance', async () => { - const first = await service() - await first.registry.register({ sessionId: SessionId('sess-1'), cwd: '/tmp/project' }) - - // A second instance stands in for another process reading the same file. - const reader = await service() - const listed = await reader.registry.list() - expect(listed).toHaveLength(1) - expect(listed[0]).toMatchObject({ - sessionId: 'sess-1', - cwd: '/tmp/project', - pid: process.pid, - }) - await first.ctx.fiber.dispose() - await reader.ctx.fiber.dispose() - }) - - it('replaces an earlier record for the same session id', async () => { - const { ctx, registry } = await service() - await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' }) - await registry.register({ sessionId: SessionId('sess-1'), cwd: '/b' }) - const listed = await registry.list() - expect(listed).toHaveLength(1) - // The later registration wins: `cwd` distinguishes the two calls. - expect(listed[0]?.cwd).toBe('/b') - await ctx.fiber.dispose() - }) - - it('creates the registry root private and the file owner-only', async () => { - const { ctx, registry } = await service() - await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' }) - expect(statSync(root).mode & 0o777).toBe(0o700) - expect(statSync(file()).mode & 0o777).toBe(0o600) - await ctx.fiber.dispose() - }) -}) - -describe('liveness pruning', () => { - it('drops a record whose process is gone', async () => { - const { ctx, registry } = await service() - await registry.register({ sessionId: SessionId('live'), cwd: '/a' }) - - // A real exited pid: spawn a process, wait for it, then claim its id. The - // kernel has reaped it, so signal 0 reports ESRCH. - const dead = await run(process.execPath, ['-e', 'process.stdout.write(String(process.pid))']) - const deadPid = Number(dead.stdout) - expect(isPidAlive(deadPid)).toBe(false) - const stored = parseRegistry(readFileSync(file(), 'utf8')).records - writeFileSync(file(), serializeRegistry([ - ...stored, - { sessionId: SessionId('ghost'), pid: deadPid, cwd: '/b', startedAt: 1, bootId: BootId('boot-x') }, - ])) - - const listed = await registry.list() - expect(listed.map(record => record.sessionId)).toEqual(['live']) - // The prune is durable, not just filtered in memory. - expect(parseRegistry(readFileSync(file(), 'utf8')).records.map(r => r.sessionId)).toEqual(['live']) - await ctx.fiber.dispose() - }) - - it('keeps a live record owned by another user (EPERM means alive)', () => { - const eperm = (): never => { - const error = new Error('operation not permitted') as NodeJS.ErrnoException - error.code = 'EPERM' - throw error - } - expect(isPidAlive(1, eperm)).toBe(true) - }) - - it('propagates an unexpected errno instead of guessing liveness', () => { - const einval = (): never => { - const error = new Error('invalid') as NodeJS.ErrnoException - error.code = 'EINVAL' - throw error - } - expect(() => isPidAlive(1, einval)).toThrow('invalid') - }) -}) - -describe('pid recycling', () => { - it('deregistration removes only this incarnation, not a namesake pid', async () => { - const { ctx, registry } = await service() - const disposer = await registry.register({ sessionId: SessionId('mine'), cwd: '/a' }) - - // A foreign record reusing THIS live pid under a different session and boot - // id: deregistering must not delete it. - const stored = parseRegistry(readFileSync(file(), 'utf8')).records - writeFileSync(file(), serializeRegistry([ - ...stored, - { sessionId: SessionId('other'), pid: process.pid, cwd: '/b', startedAt: 2, bootId: BootId('boot-other') }, - ])) - - // Awaiting the disposer is the contract: the record is durably gone when it - // settles, so the assertion needs no timing slack. - await disposer() - const listed = await registry.list() - expect(listed.map(record => record.sessionId)).toEqual(['other']) - await ctx.fiber.dispose() - }) -}) - -describe('file format', () => { - it('round-trips records', () => { - const records = [{ - sessionId: SessionId('s'), pid: 5 as const, cwd: '/c', startedAt: 7, bootId: BootId('b'), - }] - expect(parseRegistry(serializeRegistry(records))).toEqual({ records, intact: true }) - }) - - it('stamps the format version', () => { - const stamped = JSON.parse(serializeRegistry([])) as { version: number } - expect(stamped.version).toBe(SESSION_REGISTRY_FORMAT_VERSION) - }) - - it.each([ - ['torn json', '{"version":0,"records":[{'], - ['a foreign version', '{"version":99,"records":[]}'], - ['a non-object root', '[]'], - ['a null root', 'null'], - ['a non-array records field', '{"version":0,"records":{}}'], - ])('reads %s as an empty, non-intact registry', (_label, text) => { - expect(parseRegistry(text)).toEqual({ records: [], intact: false }) - }) - - it.each([ - ['a missing session id', { pid: 1, cwd: '/a', startedAt: 0, bootId: 'b' }], - ['a non-integer pid', { sessionId: 's', pid: 1.5, cwd: '/a', startedAt: 0, bootId: 'b' }], - ['a non-positive pid', { sessionId: 's', pid: 0, cwd: '/a', startedAt: 0, bootId: 'b' }], - ['an empty cwd', { sessionId: 's', pid: 1, cwd: '', startedAt: 0, bootId: 'b' }], - ['a negative startedAt', { sessionId: 's', pid: 1, cwd: '/a', startedAt: -1, bootId: 'b' }], - ['a missing boot id', { sessionId: 's', pid: 1, cwd: '/a', startedAt: 0 }], - ['a non-string title', { sessionId: 's', pid: 1, cwd: '/a', startedAt: 0, bootId: 'b', title: 7 }], - ['a non-object row', 'nonsense'], - ])('drops a row with %s but keeps its intact siblings', (_label, row) => { - const good = { sessionId: 'keep', pid: 1, cwd: '/a', startedAt: 0, bootId: 'b' } - const text = JSON.stringify({ version: SESSION_REGISTRY_FORMAT_VERSION, records: [row, good] }) - const parsed = parseRegistry(text) - expect(parsed.records.map(record => record.sessionId)).toEqual(['keep']) - expect(parsed.intact).toBe(false) - }) - - it('heals a damaged medium on the next locked write', async () => { - writeFileSync(file(), 'not json at all') - const { ctx, registry } = await service() - await registry.list() - expect(parseRegistry(readFileSync(file(), 'utf8')).intact).toBe(true) - await ctx.fiber.dispose() - }) - - it('reads a missing file as no live sessions', async () => { - const { ctx, registry } = await service() - rmSync(file(), { force: true }) - expect(await registry.list()).toEqual([]) - await ctx.fiber.dispose() - }) - -}) - -describe('failure reporting', () => { - it('tolerates a registry file another process created first', async () => { - // Two services racing `ensureFile`: the loser sees EEXIST, which is the - // intended outcome rather than an error, and both still publish. - const first = await service() - const second = await service() - await Promise.all([ - first.registry.register({ sessionId: SessionId('a'), cwd: '/a' }), - second.registry.register({ sessionId: SessionId('b'), cwd: '/b' }), - ]) - expect((await first.registry.list()).map(record => record.sessionId).sort()).toEqual(['a', 'b']) - await first.ctx.fiber.dispose() - await second.ctx.fiber.dispose() - }) - - it('warns instead of throwing when deregistration fails during teardown', async () => { - const { ctx, registry } = await service() - await registry.register({ sessionId: SessionId('doomed'), cwd: '/w' }) - // Make the registry path unusable, so the disposer's own write fails while the - // fiber is already unwinding. Teardown must still complete. - rmSync(root, { recursive: true, force: true }) - mkdirSync(join(root, REGISTRY_FILE_NAME), { recursive: true }) - await expect(ctx.fiber.dispose()).resolves.not.toThrow() - }) - - it('propagates a read failure that is not a missing file', async () => { - const { ctx, registry } = await service() - await registry.register({ sessionId: SessionId('sess-1'), cwd: '/w' }) - // A directory where the file belongs makes the read fail with EISDIR, which - // is corruption rather than "no live sessions" and must not read as empty. - rmSync(file(), { force: true }) - mkdirSync(file(), { recursive: true }) - await expect(registry.list()).rejects.toThrow() - rmSync(file(), { recursive: true, force: true }) - await ctx.fiber.dispose() - }) -}) - -describe('retitle', () => { - it('replaces the recorded title of a session this process owns', async () => { - const { ctx, registry } = await service() - await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' }) - expect((await registry.list())[0]?.title).toBeUndefined() - - await registry.retitle(SessionId('sess-1'), 'first') - expect((await registry.list())[0]?.title).toBe('first') - await registry.retitle(SessionId('sess-1'), 'second') - expect((await registry.list())[0]?.title).toBe('second') - await ctx.fiber.dispose() - }) - - it('accepts a registration that already carries a title', async () => { - const { ctx, registry } = await service() - await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a', title: 'preset' }) - expect((await registry.list())[0]?.title).toBe('preset') - await ctx.fiber.dispose() - }) - - it('leaves a same-id record owned by another incarnation untouched', async () => { - const { ctx, registry } = await service() - // Same live pid, different boot id: another incarnation's record must not be - // retitled by this one. - writeFileSync(file(), serializeRegistry([ - { sessionId: SessionId('foreign'), pid: process.pid, cwd: '/b', startedAt: 2, bootId: BootId('boot-other') }, - ])) - await registry.retitle(SessionId('foreign'), 'not mine') - expect((await registry.list())[0]?.title).toBeUndefined() - await ctx.fiber.dispose() - }) - - it('ignores an unknown session id, since a title can resolve after removal', async () => { - const { ctx, registry } = await service() - await expect(registry.retitle(SessionId('never-registered'), 'ghost')).resolves.toBeUndefined() - expect(await registry.list()).toEqual([]) - await ctx.fiber.dispose() - }) -}) - -describe('same-process concurrency', () => { - it('keeps every record when one process registers several sessions at once', async () => { - // The advisory lock is tracked per process, so same-process callers contend - // for it through its bounded retry budget instead of queueing. Past a dozen - // or so overlapping calls that budget runs out and a registration rejects — - // and callers publish fire-and-forget, so the rejection is swallowed and the - // session silently vanishes from the listing. The service therefore - // serializes its own callers; the lock only excludes other processes. - const { ctx, registry } = await service() - // Register once first so the file and directory already exist: without that, - // the concurrent calls serialize behind their own mkdir/create awaits and the - // overlap under test never happens. - await registry.register({ sessionId: SessionId('warm'), cwd: '/w' }) - const settled = await Promise.allSettled(Array.from({ length: 24 }, (_unused, index) => - registry.register({ sessionId: SessionId(`bulk-${String(index)}`), cwd: `/w/${String(index)}` }))) - - // Every call must SUCCEED, not merely leave the file consistent. Callers - // publish fire-and-forget, so a rejection is swallowed and the session - // silently vanishes from the listing rather than failing loudly. - expect(settled.filter(outcome => outcome.status === 'rejected')).toEqual([]) - const expected = [...Array.from({ length: 24 }, (_unused, index) => `bulk-${String(index)}`), 'warm'].sort() - expect((await registry.list()).map(record => record.sessionId).sort()).toEqual(expected) - await ctx.fiber.dispose() - }) - - it('keeps serving later callers after one cycle fails', async () => { - const { ctx, registry } = await service() - // A directory sitting where the registry file must be makes one cycle fail - // without breaking the shared chain for the calls queued behind it. - rmSync(root, { recursive: true, force: true }) - mkdirSync(join(root, REGISTRY_FILE_NAME), { recursive: true }) - await expect(registry.register({ sessionId: SessionId('doomed'), cwd: '/w' })).rejects.toThrow() - - rmSync(root, { recursive: true, force: true }) - await registry.register({ sessionId: SessionId('after'), cwd: '/w' }) - expect((await registry.list()).map(record => record.sessionId)).toEqual(['after']) - await ctx.fiber.dispose() - }) -}) - -describe('cross-process concurrency', () => { - it('keeps every record when independent processes register at once', async () => { - // The regression that motivates the advisory lock: unlocked whole-file - // republication loses records under concurrent writers. Real processes are - // required — same-process promises would serialize on the event loop. - const driver = fileURLToPath(new URL('./fixtures/register-once.ts', import.meta.url)) - const count = 8 - const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) - const tsx = join(repoRoot, 'node_modules/tsx/dist/loader.mjs') - // Source plane: tsx resolves the workspace import through the root - // tsconfig `paths` to `src`, so this runs without a build step. - const env = { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') } - - const children = Array.from({ length: count }, (_unused, index) => - spawn(process.execPath, ['--import', tsx, driver, root, `sess-${String(index)}`], { - env, - stdio: ['pipe', 'pipe', 'inherit'], - })) - try { - // Every child must have committed its record AND still be alive when the - // file is read, so the assertion sees concurrent writes rather than prunes. - await Promise.all(children.map(child => new Promise((resolve, reject) => { - child.stdout.once('data', () => { resolve() }) - child.once('error', reject) - child.once('exit', (code) => { reject(new Error(`driver exited early with ${String(code)}`)) }) - }))) - - const stored = parseRegistry(readFileSync(file(), 'utf8')) - expect(stored.intact).toBe(true) - expect(stored.records.map(record => record.sessionId).sort()).toEqual( - Array.from({ length: count }, (_unused, index) => `sess-${String(index)}`).sort(), - ) - } finally { - for (const child of children) child.stdin.end() - await Promise.all(children.map(child => new Promise((resolve) => { child.once('exit', () => { resolve() }) }))) - } - }, 60_000) -}) diff --git a/packages/session-registry/session-registry-file/tsconfig.json b/packages/session-registry/session-registry-file/tsconfig.json deleted file mode 100644 index 0ab9c6a3a4..0000000000 --- a/packages/session-registry/session-registry-file/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../session-registry" - }, - { - "path": "../../support/invariants" - }, - { - "path": "../../core/session" - } - ] -} diff --git a/packages/session-registry/session-registry-live/README.i18n.yaml b/packages/session-registry/session-registry-live/README.i18n.yaml deleted file mode 100644 index 2d4554f70c..0000000000 --- a/packages/session-registry/session-registry-live/README.i18n.yaml +++ /dev/null @@ -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/session-registry/session-registry-live/README.md -README.md: 0404915a97c03999210b0c4e0356cd2cecb2b040 -README.zh.md: 5bfb9a90db2578bfe6517dd9cd3d11ebd043f515 diff --git a/packages/session-registry/session-registry-live/README.md b/packages/session-registry/session-registry-live/README.md deleted file mode 100644 index 0404915a97..0000000000 --- a/packages/session-registry/session-registry-live/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# @deepseek-ai/dsh-session-registry-live - -English | [中文](README.zh.md) - -Publishes every live session in this process into the [session registry](../session-registry/README.md), so `dsh list-sessions` lists the sessions a server creates on demand rather than only the one a launcher minted up front. - -## Behavior - -Registration follows session lifecycle rather than a launcher-known identity: the plugin publishes every session present at mount and every later `session/created`, and removes a record when its session is disposed. One path therefore serves both the TUI's single session and the browser UI's one-per-conversation sessions. - -A session whose header carries no `cwd` is skipped — the listing's workspace column would have nothing truthful to show. - -`session/title` events are mirrored onto the record through `retitle`, so the latest logged title reaches the listing. Carrying the title in the record is what keeps the reader backend-agnostic: the log's location, file format, and compression are per-deployment choices (the shipped TUI writes zstd-compressed JSONL), so an independent process cannot portably parse one. - -Publication is fire-and-forget with a warning on failure: the registry is an observability aid, so a registry fault must not fail a working agent session. A session that ends while its registration is still in flight leaves a tombstone the completing registration observes, so its record cannot outlive the session until a pid-based prune. - -## Config - -None. Every published record is derived from the session itself, so no deployment-varying choice is left to configure. - -## Model Experience - -None, as this package registers no tools, injects no prompts, and appends no session events; it only mirrors existing lifecycle and title events into a host-side process record. - -#### KV Cache effect - -Independent of live requests: the plugin reads session events and writes a separate registry file without touching any request prefix, so it cannot invalidate provider cache reuse. - -## Known Limitations and Deferred Work - -- **A skipped session is invisible, not deferred** — a session created without a `cwd` is never published, even if a workspace becomes known later; there is no re-check. -- **Title mirroring costs one registry write per revision** — each `session/title` event triggers a locked read-modify-write, so a deployment with an aggressive retitling cadence pays that write per revision. diff --git a/packages/session-registry/session-registry-live/README.zh.md b/packages/session-registry/session-registry-live/README.zh.md deleted file mode 100644 index 5bfb9a90db..0000000000 --- a/packages/session-registry/session-registry-live/README.zh.md +++ /dev/null @@ -1,32 +0,0 @@ -# @deepseek-ai/dsh-session-registry-live - -[English](README.md) | 中文 - -把本进程内每个活跃会话发布到[会话注册表](../session-registry/README.md),因此 `dsh list-sessions` 能列出服务端按需创建的所有会话,而不是只列出启动器一开始铸出的那一个。 - -## 行为 - -注册跟随会话生命周期,而不依赖启动器已知的身份:插件会发布挂载时已存在的每个会话,以及此后每个 `session/created`,并在会话被 dispose(资源释放)时移除对应记录。因此同一条路径既服务 TUI 的单个会话,也服务浏览器 UI 的每对话一个的多个会话。 - -会话头不带 `cwd` 时会被跳过:列表的工作区列拿不到任何真实内容可展示。 - -`session/title` 事件通过 `retitle` 镜像到记录上,因此最新记录的标题能到达列表。把标题带在记录里,正是让读取方与后端无关的原因:日志的位置、文件格式和压缩都是逐部署的选择(随附的 TUI 写入 Zstandard 压缩的 JSONL),因此独立进程无法以可移植的方式解析它。 - -发布是 fire-and-forget,失败只发出警告:注册表是一项可观测性辅助设施,因此注册表故障绝不能让正常工作的 agent(智能体)会话失败。会话在其注册仍在途中时结束,会留下一个 tombstone,让即将完成的注册观测到,因此它的记录不会一直存活到某次基于 pid 的清理才消失。 - -## 配置 - -无。每条发布的记录都从会话本身派生而来,因此没有留下任何逐部署的选择需要配置。 - -## 模型体验 - -无。该包(package)不注册工具、不注入提示词,也不追加会话事件;它只把既有的生命周期事件和标题事件镜像进宿主侧的进程记录。 - -#### KV 缓存影响 - -与实时请求相互独立:该插件读取会话事件,并写入一个独立的注册表文件,不触碰任何请求前缀,因此它无法使提供方 cache 复用失效。 - -## 已知限制与延期工作 - -- **被跳过的会话是不可见,而非延后处理**——创建时不带 `cwd` 的会话永不发布,即使之后工作区变为已知也不会;没有重新检查机制。 -- **标题镜像每次修订都要付出一次注册表写入**——每个 `session/title` 事件都会触发一次加锁的读取、修改和写入,因此改名节奏激进的部署要按修订次数付出这些写入。 diff --git a/packages/session-registry/session-registry-live/package.json b/packages/session-registry/session-registry-live/package.json deleted file mode 100644 index f0c5aeddc6..0000000000 --- a/packages/session-registry/session-registry-live/package.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-session-registry-live", - "description": "Publishes every live session into the cross-process session registry that `dsh list-sessions` reads", - "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": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-registry": "^0.0.1", - "@deepseek-ai/dsh-session-title": "^0.0.1", - "cordis": "^4.0.0-rc.6" - }, - "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-registry": "workspace:^", - "@deepseek-ai/dsh-session-registry-file": "workspace:^", - "@deepseek-ai/dsh-session-title": "workspace:^", - "cordis": "^4.0.0-rc.6" - } -} diff --git a/packages/session-registry/session-registry-live/src/index.ts b/packages/session-registry/session-registry-live/src/index.ts deleted file mode 100644 index 8e34487e65..0000000000 --- a/packages/session-registry/session-registry-live/src/index.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Publishes every live session in this process into the cross-process session - * registry, so `dsh list-sessions` lists sessions a server creates on demand rather than - * only the one a launcher minted up front. - * - * Mounted in a composition whose sessions come and go — the browser UI creates - * one per conversation — this plugin follows `session/created` and - * `session/disposed` instead of registering a single launcher-known identity. - * A session with no `cwd` in its header is skipped: the registry's workspace - * column would have nothing truthful to show, and a subagent child is exactly - * that case. Titles are mirrored into the record as `session/title` events - * arrive, so a reader never has to parse a backend's log format. - * @module @deepseek-ai/dsh-session-registry-live - */ - -import type { Context } from 'cordis' -import type { Session } from '@deepseek-ai/dsh-session' -// Empty type imports carry the Context merges this plugin relies on: the -// `sessionRegistry` service and the `session/title` session event. -import type {} from '@deepseek-ai/dsh-session-registry' -import type {} from '@deepseek-ai/dsh-session-title' - -/** Cordis plugin name. */ -export const name = 'session-registry-live' - -/** Services required before sessions can be followed and records published. */ -export const inject = ['sessions', 'sessionRegistry'] - -/** - * Follow session lifecycle and keep the registry in step. - * @param ctx - context carrying the session store and the registry service. - */ -export function apply(ctx: Context): void { - /** - * Per-session registration state. `'disposing'` is a tombstone written when a - * session ends while its registration is still in flight: without it the - * late-arriving disposer would be stored for a session that no longer exists - * and its record would outlive the session until a pid-based prune. - */ - const registered = new Map Promise) | 'disposing'>() - - const publish = (session: Session): void => { - const cwd = session.header.cwd - // A session without a workspace has no listable location; skipping keeps the - // registry free of rows `dsh list-sessions` could not render truthfully. - if (cwd === undefined) return - void ctx.sessionRegistry.register({ sessionId: session.id, cwd }) - .then((dispose) => { - if (registered.get(session) === 'disposing') { - registered.delete(session) - void dispose() - return - } - registered.set(session, dispose) - }) - .catch((error: unknown) => { - registered.delete(session) - ctx.logger.warn('failed to publish session %s: %s', session.id, String(error)) - }) - } - - for (const session of ctx.sessions.list()) publish(session) - ctx.on('session/created', (session) => { publish(session) }, { global: true }) - ctx.on('session/disposed', (session) => { - const entry = registered.get(session) - if (typeof entry === 'function') { - registered.delete(session) - void entry() - return - } - // Registration is still in flight; leave a tombstone for it to observe. - registered.set(session, 'disposing') - }, { global: true }) - - // Mirror title revisions onto the record. A title arrives after registration - // and may be replaced, so the listing tracks the latest logged value. - ctx.on('session/event', (session, event) => { - if (event.type !== 'session/title') return - const { title } = event.data - void ctx.sessionRegistry.retitle(session.id, title).catch((error: unknown) => { - ctx.logger.warn('failed to retitle %s: %s', session.id, String(error)) - }) - }, { global: true }) -} diff --git a/packages/session-registry/session-registry-live/src/invariant.ts b/packages/session-registry/session-registry-live/src/invariant.ts deleted file mode 100644 index 19d2a9f483..0000000000 --- a/packages/session-registry/session-registry-live/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-session-registry-live`. - * @module @deepseek-ai/dsh-session-registry-live/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-session-registry-live' - -/** Cordis companion plugin name. */ -export const name = 'session-registry-live-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this plugin owns no durable state of its own — the - * uniqueness and liveness relations over published records are checked by the - * companion in `@deepseek-ai/dsh-session-registry`, which owns that file. - */ -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 */ diff --git a/packages/session-registry/session-registry-live/tests/session-registry-live.spec.ts b/packages/session-registry/session-registry-live/tests/session-registry-live.spec.ts deleted file mode 100644 index ec0973259e..0000000000 --- a/packages/session-registry/session-registry-live/tests/session-registry-live.spec.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * Tests for the live-session publisher over the REAL session store, so - * publication follows the store's actual lifecycle dispatch rather than a - * hand-built event emitter: sessions created after mount are published, - * disposal removes their records, a session without a workspace is skipped, and - * logged title revisions are mirrored onto the record so a reader never parses a - * backend's log format. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import { type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry' -import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file' -import * as live from '@deepseek-ai/dsh-session-registry-live' -// Empty type import carries the `session/title` event into the session-event map. -import type {} from '@deepseek-ai/dsh-session-title' - -let root: string - -beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'dsh-registry-live-test-')) }) -afterEach(() => { - rmSync(root, { recursive: true, force: true }) - vi.restoreAllMocks() -}) - -/** Mount the real store plus the publisher. */ -async function mount(): Promise { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 }) - await ctx.plugin(live) - return ctx -} - -/** Let the publisher's fire-and-forget registration reach durability. */ -const settle = (): Promise => new Promise((resolve) => { setTimeout(resolve, 200) }) - -/** Read the registry through an independent service, as `dsh list-sessions` would. */ -async function listExternally(): Promise { - const reader = new Context() - await reader.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 }) - const records = await reader.sessionRegistry.list() - await reader.fiber.dispose() - return records -} - -describe('publishing', () => { - it('publishes sessions that already exist when the plugin mounts', async () => { - // A composition may mount the publisher after sessions exist (a resumed - // session, or plugin order), so mount-time adoption is its own path. - const ctx = new Context() - await ctx.plugin(SessionStore) - ctx.sessions.create(SessionId('preexisting'), { meta: { cwd: '/work/a' } }) - await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 }) - await ctx.plugin(live) - await settle() - - expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['preexisting']) - await ctx.fiber.dispose() - }) - - it('publishes a session created after mount', async () => { - const ctx = await mount() - ctx.sessions.create(SessionId('later'), { meta: { cwd: '/work/b' } }) - await settle() - - const listed = await ctx.sessionRegistry.list() - expect(listed).toHaveLength(1) - expect(listed[0]).toMatchObject({ sessionId: 'later', cwd: '/work/b' }) - await ctx.fiber.dispose() - }) - - it('skips a session with no workspace, having nothing truthful to list', async () => { - const ctx = await mount() - ctx.sessions.create(SessionId('no-cwd')) - await settle() - expect(await ctx.sessionRegistry.list()).toEqual([]) - await ctx.fiber.dispose() - }) - - it('has no title until one is logged', async () => { - const ctx = await mount() - ctx.sessions.create(SessionId('fresh'), { meta: { cwd: '/work/c' } }) - await settle() - expect((await ctx.sessionRegistry.list())[0]?.title).toBeUndefined() - await ctx.fiber.dispose() - }) - - it('mirrors the latest logged title onto the record', async () => { - const ctx = await mount() - const session = ctx.sessions.create(SessionId('titled'), { meta: { cwd: '/work/d' } }) - await settle() - - session.append('session/title', { title: 'first guess', messageSeqs: [0], source: { kind: 'fallback' } }) - await settle() - expect((await ctx.sessionRegistry.list())[0]?.title).toBe('first guess') - - // A revision replaces the previous value rather than accumulating. - session.append('session/title', { title: 'better title', messageSeqs: [0], source: { kind: 'fallback' } }) - await settle() - expect((await ctx.sessionRegistry.list())[0]?.title).toBe('better title') - await ctx.fiber.dispose() - }) - - it('ignores session events other than a title revision', async () => { - const ctx = await mount() - const session = ctx.sessions.create(SessionId('busy'), { meta: { cwd: '/work/z' } }) - await settle() - const retitle = vi.spyOn(ctx.sessionRegistry, 'retitle') - - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - await settle() - expect(retitle).not.toHaveBeenCalled() - await ctx.fiber.dispose() - }) - - it('retitles only the session that logged the event', async () => { - const ctx = await mount() - const first = ctx.sessions.create(SessionId('one'), { meta: { cwd: '/work/e' } }) - ctx.sessions.create(SessionId('two'), { meta: { cwd: '/work/f' } }) - await settle() - - first.append('session/title', { title: 'only mine', messageSeqs: [0], source: { kind: 'fallback' } }) - await settle() - const byId = new Map((await ctx.sessionRegistry.list()).map(record => [record.sessionId, record.title])) - expect(byId.get(SessionId('one'))).toBe('only mine') - expect(byId.get(SessionId('two'))).toBeUndefined() - await ctx.fiber.dispose() - }) - - it('publishes every concurrently created session', async () => { - const ctx = await mount() - for (let index = 0; index < 5; index += 1) { - ctx.sessions.create(SessionId(`bulk-${String(index)}`), { meta: { cwd: `/work/bulk-${String(index)}` } }) - } - await settle() - expect((await ctx.sessionRegistry.list()).map(record => record.sessionId).sort()) - .toEqual(['bulk-0', 'bulk-1', 'bulk-2', 'bulk-3', 'bulk-4']) - await ctx.fiber.dispose() - }) -}) - -describe('failure and race handling', () => { - it('removes the record when a session is disposed mid-registration', async () => { - // The tombstone path: the session ends before its registration resolves, so - // the late disposer must be applied instead of stored for a dead session. - const ctx = await mount() - let owner: Context | undefined - await ctx.plugin({ - inject: ['sessions'], - apply: (child: Context) => { - owner = child - child.sessions.create(SessionId('raced'), { meta: { cwd: '/work/race' } }) - }, - }) - // No settle: dispose while `register` is still in flight. - await owner?.fiber.dispose() - await settle() - expect(await ctx.sessionRegistry.list()).toEqual([]) - await ctx.fiber.dispose() - }) - - it('warns and drops the record when publication fails', async () => { - const ctx = await mount() - ctx.sessionRegistry.register = () => Promise.reject(new Error('registry offline')) - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - - ctx.sessions.create(SessionId('unpublishable'), { meta: { cwd: '/work/x' } }) - await settle() - expect(warn.mock.calls.flat().join(' ')).toMatch(/failed to publish session/) - await ctx.fiber.dispose() - }) - - it('warns when a title revision cannot be recorded', async () => { - const ctx = await mount() - const session = ctx.sessions.create(SessionId('titled'), { meta: { cwd: '/work/y' } }) - await settle() - - ctx.sessionRegistry.retitle = () => Promise.reject(new Error('registry offline')) - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - - session.append('session/title', { title: 'doomed', messageSeqs: [0], source: { kind: 'fallback' } }) - await settle() - expect(warn.mock.calls.flat().join(' ')).toMatch(/failed to retitle/) - await ctx.fiber.dispose() - }) -}) - -describe('disposal', () => { - it('removes a record when its own session is disposed, keeping the others', async () => { - const ctx = await mount() - // A session belongs to the fiber that created it, so a child plugin fiber - // gives one session an independent lifetime without disposing the services. - let owner: Context | undefined - await ctx.plugin({ - inject: ['sessions'], - apply: (child: Context) => { - owner = child - child.sessions.create(SessionId('ephemeral'), { meta: { cwd: '/work/e' } }) - }, - }) - ctx.sessions.create(SessionId('durable'), { meta: { cwd: '/work/f' } }) - await settle() - expect(await ctx.sessionRegistry.list()).toHaveLength(2) - - // Disposing only that fiber ends its session, which the publisher follows. - await owner?.fiber.dispose() - await settle() - expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['durable']) - await ctx.fiber.dispose() - }) - - it('leaves no record behind after the whole tree unloads', async () => { - const ctx = await mount() - ctx.sessions.create(SessionId('a'), { meta: { cwd: '/work/g' } }) - ctx.sessions.create(SessionId('b'), { meta: { cwd: '/work/h' } }) - await settle() - expect(await ctx.sessionRegistry.list()).toHaveLength(2) - - await ctx.fiber.dispose() - expect(await listExternally()).toEqual([]) - }) -}) diff --git a/packages/session-registry/session-registry-live/tsconfig.json b/packages/session-registry/session-registry-live/tsconfig.json deleted file mode 100644 index 456adfc51b..0000000000 --- a/packages/session-registry/session-registry-live/tsconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../support/invariants" - }, - { - "path": "../../core/session" - }, - { - "path": "../session-registry" - }, - { - "path": "../../session-title/session-title" - } - ] -} diff --git a/packages/session-registry/session-registry/README.i18n.yaml b/packages/session-registry/session-registry/README.i18n.yaml deleted file mode 100644 index 211c8222e8..0000000000 --- a/packages/session-registry/session-registry/README.i18n.yaml +++ /dev/null @@ -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/session-registry/session-registry/README.md -README.md: 8ab8e232e6cd041e5476d4e6cadcb8b64a85f586 -README.zh.md: 62774e8a2a892c97ba6343f12dd35f827ad63e33 diff --git a/packages/session-registry/session-registry/README.md b/packages/session-registry/session-registry/README.md deleted file mode 100644 index 8ab8e232e6..0000000000 --- a/packages/session-registry/session-registry/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# @deepseek-ai/dsh-session-registry - -English | [中文](README.zh.md) - -Live-session registry seam (`ctx.sessionRegistry`): the contract and record vocabulary for a cross-process registry of the sessions running right now, so a separate short-lived process such as `dsh list-sessions` can answer "what am I running". This package owns no medium — a backend (the lock-guarded JSON file in [`session-registry-file`](../session-registry-file/README.md) today, a database later) implements the abstract service. - -## Shape - -- `register(registration)` — publish `{ sessionId, cwd, title? }` stamped with this process's pid, a per-incarnation `bootId`, and `startedAt`. Replaces any existing record for the same session id. Returns the `ctx.effect` disposer; awaiting it waits for the removal to reach durability. -- `retitle(sessionId, title)` — replace the recorded title of a session **this** process registered. Titles arrive after registration and can be revised, so it is the one mutable field. A record owned by another pid or incarnation is left alone, and an unknown id is a no-op because a title can resolve after the record is gone. -- `list()` — every live record, newest registration last. Liveness is part of the contract, not the backend's discretion: every returned record's process existed at observation time, so a process killed without running its disposer leaves no permanent phantom. - -Backends serialize mutations against concurrent registrars — other processes and overlapping calls in this one — so records are never lost to a torn read-modify-write. - -## Record vocabulary - -`SessionRegistryRecord` carries `sessionId` (unique across live records), `pid`, `cwd`, `startedAt`, a `bootId` distinguishing a recycled pid from the original incarnation, and an optional `title`. The title travels in the record rather than being read from the session log because log location, format, and compression are per-deployment backend choices an independent reader cannot portably parse. - -## Model Experience - -None, as this package registers no tools, injects no prompts, and appends no session events; it defines the host-side listing contract only. - -#### KV Cache effect - -Independent of live requests: the registry never touches a request prefix, so nothing here can invalidate provider cache reuse. - -## Known Limitations and Deferred Work - -- **Records are process-scoped, not agent-scoped** — only top-level launcher surfaces publish. In-process subagents have no process of their own, and out-of-process subagent backends spawn `dsh-jsonrpc-agent` rather than the CLI, so neither appears in a listing. -- **Liveness is pid existence, not health** — a hung or stopped process still lists as running; the contract deliberately makes no judgement about whether a session is making progress. diff --git a/packages/session-registry/session-registry/README.zh.md b/packages/session-registry/session-registry/README.zh.md deleted file mode 100644 index 62774e8a2a..0000000000 --- a/packages/session-registry/session-registry/README.zh.md +++ /dev/null @@ -1,30 +0,0 @@ -# @deepseek-ai/dsh-session-registry - -[English](README.md) | 中文 - -存活会话注册表 seam(`ctx.sessionRegistry`):定义跨进程「当前正在运行哪些会话」注册表的契约与记录词汇,使 `dsh list-sessions` 这类独立的短生命周期进程能够回答「我正在运行什么」。本包不拥有任何介质——由后端实现该抽象服务(今天是 [`session-registry-file`](../session-registry-file/README.md) 中加锁保护的 JSON 文件,将来可以是数据库)。 - -## 形状 - -- `register(registration)`:发布 `{ sessionId, cwd, title? }`,并盖上本进程的 pid、每个 incarnation 独有的 `bootId` 和 `startedAt`。同一会话 id 的既有记录会被替换。返回 `ctx.effect` disposer;await 它即等待移除达到持久性。 -- `retitle(sessionId, title)`:替换**本**进程注册的某个会话的已记录标题。标题在注册之后才到达,并且可以修订,因此它是唯一的可变字段。归属于其他 pid 或其他 incarnation 的记录不受影响;未知 id 为空操作,因为标题可能在记录消失之后才解析出来。 -- `list()`:返回全部存活记录,按注册时间从旧到新排列。存活性属于契约本身,而非后端的自由裁量:每条返回记录的进程在观察时刻都存在,因此未运行 disposer 就被杀掉的进程不会留下永久的幽灵记录。 - -后端必须将变更与并发注册方(其他进程,以及本进程内相互重叠的调用)串行化,使记录不会因撕裂的读改写而丢失。 - -## 记录词汇 - -`SessionRegistryRecord` 携带 `sessionId`(在存活记录中唯一)、`pid`、`cwd`、`startedAt`、用于区分被复用 pid 与原 incarnation 的 `bootId`,以及可选的 `title`。标题随记录传递而非从会话日志读取,因为日志的位置、格式与压缩是各部署后端的选择,独立读取方无法可移植地解析。 - -## 模型体验 - -无。本包不注册工具、不注入提示词、不追加会话事件;它只定义宿主侧的列表契约。 - -#### KV 缓存影响 - -与在途请求无关:注册表从不触碰请求前缀,因此这里不会使提供方缓存复用失效。 - -## 已知限制与后续工作 - -- **记录以进程为粒度,而非以 agent 为粒度**——只有用户直接启动的顶层界面会发布。进程内 subagent 没有自己的进程,进程外 subagent 后端启动的是 `dsh-jsonrpc-agent` 而非本 CLI,两者都不会出现在列表中。 -- **存活性只表示 pid 存在,不表示健康**——挂起或停止的进程仍会被列为运行中;契约刻意不判断会话是否在推进。 diff --git a/packages/session-registry/session-registry/package.json b/packages/session-registry/session-registry/package.json deleted file mode 100644 index 75308999f5..0000000000 --- a/packages/session-registry/session-registry/package.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-session-registry", - "description": "Live-session registry seam for the DeepSeek Harness: contract and record vocabulary", - "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": { - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.6" - }, - "devDependencies": { - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.6" - } -} diff --git a/packages/session-registry/session-registry/src/index.ts b/packages/session-registry/session-registry/src/index.ts deleted file mode 100644 index 413317c31c..0000000000 --- a/packages/session-registry/session-registry/src/index.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Live-session registry seam (`ctx.sessionRegistry`): a cross-process registry - * of live `dsh` sessions, so a separate short-lived process such as - * `dsh list-sessions` can answer "what am I running right now". - * - * This package owns only the service contract and the record vocabulary; a - * backend (the lock-guarded JSON file in - * `@deepseek-ai/dsh-session-registry-file` today, a database later) owns the - * medium. Whatever the medium, liveness is part of the contract: {@link list} - * returns only records whose process existed at observation time, so a process - * killed without running its disposer leaves no permanent phantom. - * @module @deepseek-ai/dsh-session-registry - */ - -import { Context, Service } from 'cordis' -import type { SessionId } from '@deepseek-ai/dsh-session' -import { BootId, type SessionRegistryRecord } from './types.ts' - -export { BootId } from './types.ts' -export type { SessionRegistryRecord } from './types.ts' - -declare module 'cordis' { - interface Context { - sessionRegistry: SessionRegistry - } -} - -/** What one process publishes about itself; the service supplies pid and timing. */ -export interface SessionRegistration { - /** The session this process runs. */ - sessionId: SessionId - /** Absolute workspace directory the session acts on. */ - cwd: string - /** Human-readable session title, when one already exists. */ - title?: string -} - -/** - * Cross-process live-session registry. Reads prune dead records, so every - * returned record's process existed at observation time. Backends serialize - * mutations against concurrent registrars — other processes and overlapping - * calls in this one — so records are never lost to a torn read-modify-write. - */ -export abstract class SessionRegistry extends Service { - /** This process incarnation's id, stamped into every record it publishes. */ - protected readonly bootId: BootId - - constructor(ctx: Context, bootId: BootId) { - super(ctx, 'sessionRegistry') - this.bootId = bootId - } - - /** - * Publish this process's record, replacing any stale record for the same - * session id, and prune records whose process is gone. - * @param registration - the session, surface, and workspace to publish. - * @returns the effect disposer that removes this record again; awaiting it - * waits for the removal to reach durability. - */ - abstract register(registration: SessionRegistration): Promise<() => Promise> - - /** - * Replace the recorded title of a session this process registered. - * - * Titles arrive after registration and can be revised, so this is the one - * mutable field. Only a record matching this process and incarnation is - * touched, leaving a same-id record owned by another process alone. An unknown - * session id is a no-op rather than an error: a title can resolve after the - * session's record has already been removed. - * @param sessionId - the session whose recorded title changes. - * @param title - the new title text. - */ - abstract retitle(sessionId: SessionId, title: string): Promise - - /** - * List live sessions, pruning records whose process no longer exists. - * @returns one record per live registered session, newest registration last. - */ - abstract list(): Promise -} - -export default SessionRegistry diff --git a/packages/session-registry/session-registry/src/invariant.ts b/packages/session-registry/session-registry/src/invariant.ts deleted file mode 100644 index f41207fbed..0000000000 --- a/packages/session-registry/session-registry/src/invariant.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-session-registry`. - * @module @deepseek-ai/dsh-session-registry/invariant - */ - -import type { Context } from 'cordis' -import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' -import type { SessionRegistryRecord } from './types.ts' - -const PACKAGE_NAME = '@deepseek-ai/dsh-session-registry' - -/** Cordis companion plugin name. */ -export const name = 'session-registry-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * Cross-check every published listing against the relations the seam contract - * owns: a session id identifies at most one live record, and each listed record - * carries the identity fields a reader must be able to trust. Only a backend's - * mutation path can break either, so the check wraps the authoritative read - * rather than inspecting any medium. - * - * Liveness itself is deliberately not re-probed here. A backend derives it at - * read time, so a second probe would race the first and report a process that - * exited in between as a violation of a contract the seam never made. - */ -const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { - const service = ctx.sessionRegistry - const listed = service.list.bind(service) - ctx.effect(() => { - service.list = async (): Promise => { - const records = await listed() - const seen = new Set() - for (const record of records) { - if (seen.has(record.sessionId)) { - fail(`session ${record.sessionId} appears in more than one live registry record`) - } - seen.add(record.sessionId) - // A record a reader cannot attribute to a process is unusable: `dsh list-sessions` - // renders the pid and derives liveness from it. - if (!Number.isSafeInteger(record.pid) || record.pid <= 0) { - fail(`listed session ${record.sessionId} carries unusable pid ${String(record.pid)}`) - } - } - return records - } - return () => { service.list = listed } - }) -}, { inject: ['sessionRegistry'] }) - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/session-registry/session-registry/src/types.ts b/packages/session-registry/session-registry/src/types.ts deleted file mode 100644 index 6fff91648e..0000000000 --- a/packages/session-registry/session-registry/src/types.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Registry record vocabulary: the durable shape one live `dsh` process - * publishes about itself and `dsh list-sessions` reads back. - * @module @deepseek-ai/dsh-session-registry/types - */ - -import type { Branded } from '@deepseek-ai/dsh-brand' -import type { SessionId } from '@deepseek-ai/dsh-session' - -/** - * Identifies one process incarnation. Minted per registering process, so a - * record whose `pid` was recycled by the operating system cannot be mistaken - * for the original: the boot id differs even when the pid matches. - */ -export type BootId = Branded<'BootId'> - -/** - * Brand a string as a {@link BootId}. - * @param id - the raw boot id string. - * @returns the same string, branded (a compile-time cast — no runtime cost). - */ -export function BootId(id: string): BootId { - return id as BootId -} - -/** - * One live session's self-published registration. Every field is immutable for - * the lifetime of the registration: a process publishes once at startup and - * removes the record on exit, never mutating it in place. - * - * Only top-level surfaces a user starts directly register: in-process subagents - * have no process of their own, and out-of-process subagent backends spawn - * `dsh-jsonrpc-agent` rather than this CLI, so neither can reach the registry. - */ -export interface SessionRegistryRecord { - /** The session this process is running. Unique across live records. */ - readonly sessionId: SessionId - /** Operating-system process id, used with `bootId` to decide liveness. */ - readonly pid: number - /** Absolute workspace directory the session acts on. */ - readonly cwd: string - /** Non-negative safe-integer Unix epoch milliseconds when the process registered. */ - readonly startedAt: number - /** This process incarnation's id, distinguishing a recycled `pid`. */ - readonly bootId: BootId - /** - * Human-readable session title, as the registering process last knew it. - * - * Carried in the record rather than read from the session log: the log's - * location, file format, and compression are per-deployment backend choices, - * so an independent reader cannot portably parse one. Absent until a title - * exists — a fresh session has none. - */ - readonly title?: string -} diff --git a/packages/session-registry/session-registry/tests/invariant.spec.ts b/packages/session-registry/session-registry/tests/invariant.spec.ts deleted file mode 100644 index 6d97b3d129..0000000000 --- a/packages/session-registry/session-registry/tests/invariant.spec.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Tests for the registry's invariant companion: each acceptance path is proven - * to REJECT an invalid case, since a check that cannot fail is not a check. - * The backend is a minimal in-memory stub — the companion owns contract-level - * relations over `list()` results, whatever medium serves them. - */ - -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import InvariantService from '@deepseek-ai/dsh-invariants' -import { SessionId } from '@deepseek-ai/dsh-session' -import { BootId, SessionRegistry, type SessionRegistration, type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry' -import * as invariant from '@deepseek-ai/dsh-session-registry/src/invariant.ts' - -/** Minimal in-memory backend whose listings the test scripts directly. */ -class StubRegistry extends SessionRegistry { - records: SessionRegistryRecord[] = [] - - constructor(ctx: Context) { - super(ctx, BootId('stub-boot')) - } - - register(registration: SessionRegistration): Promise<() => Promise> { - this.records.push({ - sessionId: registration.sessionId, - pid: process.pid, - cwd: registration.cwd, - startedAt: Date.now(), - bootId: this.bootId, - }) - return Promise.resolve(() => Promise.resolve()) - } - - retitle(): Promise { - return Promise.resolve() - } - - list(): Promise { - return Promise.resolve([...this.records]) - } -} - -/** One record with the given identity fields, live by construction. */ -function record(sessionId: string, boot: string, pid = process.pid): SessionRegistryRecord { - return { sessionId: SessionId(sessionId), pid, cwd: '/w', startedAt: 1, bootId: BootId(boot) } -} - -/** Mount the stub backend, optionally seeding records before the companion wraps `list`. */ -async function mount(records?: SessionRegistryRecord[]): Promise<{ ctx: Context; stub: StubRegistry }> { - const ctx = new Context() - await ctx.plugin(InvariantService, { enabled: true }) - await ctx.plugin(StubRegistry) - const stub = ctx.sessionRegistry as StubRegistry - if (records !== undefined) stub.records = records - await ctx.plugin(invariant) - return { ctx, stub } -} - -describe('listing invariants', () => { - it('accepts a well-formed listing', async () => { - const { ctx } = await mount() - await ctx.sessionRegistry.register({ sessionId: SessionId('ok'), cwd: '/w' }) - await expect(ctx.sessionRegistry.list()).resolves.toHaveLength(1) - await ctx.fiber.dispose() - }) - - it('rejects a listing where one session id appears twice', async () => { - // Two live records for one session: only a broken mutation path (or an - // out-of-band writer) can produce this, and it would make - // `dsh list-sessions` show one session twice. - const { ctx } = await mount([record('dup', 'boot-a'), record('dup', 'boot-b')]) - await expect(ctx.sessionRegistry.list()).rejects.toThrow(/appears in more than one live registry record/) - await ctx.fiber.dispose() - }) - - it('rejects a listing whose record carries an unusable pid', async () => { - // A record no reader could attribute to a process: `dsh list-sessions` - // renders the pid and derives liveness from it. - const { ctx } = await mount([record('ghost', 'boot-x', 0)]) - await expect(ctx.sessionRegistry.list()).rejects.toThrow(/carries unusable pid/) - await ctx.fiber.dispose() - }) - - it('stops checking, and keeps working, when the companion unloads', async () => { - // A duplicate-id listing the mounted companion rejects, so the post-disposal - // read proves the wrapper is gone rather than merely bypassed. - const ctx = new Context() - await ctx.plugin(InvariantService, { enabled: true }) - await ctx.plugin(StubRegistry) - ;(ctx.sessionRegistry as StubRegistry).records = [record('dup', 'boot-a'), record('dup', 'boot-b')] - const companion = await ctx.plugin(invariant) - await expect(ctx.sessionRegistry.list()).rejects.toThrow(/appears in more than one/) - - await companion.dispose() - await expect(ctx.sessionRegistry.list()).resolves.toHaveLength(2) - await ctx.fiber.dispose() - }) -}) diff --git a/packages/session-registry/session-registry/tsconfig.json b/packages/session-registry/session-registry/tsconfig.json deleted file mode 100644 index cca8f9282b..0000000000 --- a/packages/session-registry/session-registry/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../support/invariants" - }, - { - "path": "../../util/brand" - }, - { - "path": "../../core/session" - } - ] -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8dc9613dc0..6e54ba3a05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3889,70 +3889,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/session-registry/session-registry: - devDependencies: - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - - packages/session-registry/session-registry-file: - dependencies: - proper-lockfile: - specifier: ^4.1.2 - version: 4.1.2 - schemastery: - specifier: ^3.15.0 - version: 3.18.0 - devDependencies: - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-session-registry': - specifier: workspace:^ - version: link:../session-registry - '@types/proper-lockfile': - specifier: ^4.1.4 - version: 4.1.4 - cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - - packages/session-registry/session-registry-live: - devDependencies: - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-session-registry': - specifier: workspace:^ - version: link:../session-registry - '@deepseek-ai/dsh-session-registry-file': - specifier: workspace:^ - version: link:../session-registry-file - '@deepseek-ai/dsh-session-title': - specifier: workspace:^ - version: link:../../session-title/session-title - cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/session-title/session-title: dependencies: schemastery: @@ -8063,9 +7999,6 @@ packages: '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} - '@types/proper-lockfile@4.1.4': - resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} - '@types/react-dom@18.3.7': resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} peerDependencies: @@ -9169,9 +9102,6 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} @@ -10159,9 +10089,6 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} - proper-lockfile@4.1.2: - resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} - property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} @@ -10265,10 +10192,6 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - retry@0.12.0: - resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} - engines: {node: '>= 4'} - retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} @@ -10410,9 +10333,6 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -12982,10 +12902,6 @@ snapshots: '@types/prop-types@15.7.15': {} - '@types/proper-lockfile@4.1.4': - dependencies: - '@types/retry': 0.12.0 - '@types/react-dom@18.3.7(@types/react@18.3.31)': dependencies: '@types/react': 18.3.31 @@ -14287,8 +14203,6 @@ snapshots: gopd@1.2.0: {} - graceful-fs@4.2.11: {} - hachure-fill@0.5.2: {} handlebars@4.7.9: @@ -15469,12 +15383,6 @@ snapshots: process-nextick-args@2.0.1: {} - proper-lockfile@4.1.2: - dependencies: - graceful-fs: 4.2.11 - retry: 0.12.0 - signal-exit: 3.0.7 - property-information@7.2.0: {} protobufjs@7.6.4: @@ -15624,8 +15532,6 @@ snapshots: resolve-pkg-maps@1.0.0: {} - retry@0.12.0: {} - retry@0.13.1: {} rfdc@1.4.1: {} @@ -15862,8 +15768,6 @@ snapshots: siginfo@2.0.0: {} - signal-exit@3.0.7: {} - signal-exit@4.1.0: {} sisteransi@1.0.5: {} diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 90a5bef153..0f3270f8ee 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -229,8 +229,6 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = { DirectoryPickerCapability: 'picker interaction contract is owned by packages/host/directory-picker/README.md', CreateAgentOptions: 'agent creation contract is owned by packages/core/agent/README.md', Domain: 'domain interface is owned by packages/storage/storage-domain/README.md', - SessionRegistration: 'registry publication input is owned by packages/session-registry/session-registry/README.md', - SessionRegistryRecord: 'live-session record vocabulary is owned by packages/session-registry/session-registry/README.md', DomainChanged: 'event-local snapshot is owned by packages/storage/storage-domain/src/events.ts', DomainFacility: 'domain form facility is owned by packages/storage/storage-domain/README.md', DomainImpl: 'domain implementation contract is owned by packages/storage/storage-domain/README.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index c836289049..73ba05f748 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -171,15 +171,6 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['apiproxy'], note: 'Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections.', }, - { - key: 'sessionRegistry', - pkg: 'session-registry', - title: 'Live-session registry', - mode: 'seam', - implementations: ['session-registry-file'], - consumers: ['session-registry-live'], - note: 'Seam contract for live-session records; the file backend owns the lock-guarded medium, liveness is derived from the recorded pid at read time, and the publisher mirrors lifecycle and title events for `dsh list-sessions`.', - }, { key: 'sessionQuery', pkg: 'session-query', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 58ac34a3cf..fd74c6d13e 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -98,9 +98,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/session-projection/session-projection': { kind: 'none', reason: 'The projection registry serves client-facing read models of already-logged session state and registers no model surface.' }, 'packages/session-projection/session-projection-cache': { kind: 'none', reason: 'The persisted cache accelerates host-side cold reads of projection state and registers no model surface.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, - 'packages/session-registry/session-registry': { kind: 'none', reason: 'The seam defines the host-side listing contract and registers no model surface.' }, - 'packages/session-registry/session-registry-file': { kind: 'none', reason: 'The file backend stores host-side process records for the CLI listing surface and registers no model surface.' }, - 'packages/session-registry/session-registry-live': { kind: 'none', reason: 'The publisher mirrors lifecycle and title events into host-side process records and registers no model surface.' }, 'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' }, 'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' }, 'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index ba1f88779c..2e35d53c43 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -88,7 +88,6 @@ "./packages/session-persistence/*/src/invariant.ts", "./packages/session-projection/*/src/invariant.ts", "./packages/session-query/*/src/invariant.ts", - "./packages/session-registry/*/src/invariant.ts", "./packages/telemetry/*/src/invariant.ts", "./packages/acp/*/src/invariant.ts", "./packages/storage/*/src/invariant.ts", @@ -180,7 +179,6 @@ "./packages/session-persistence/*/src", "./packages/session-projection/*/src", "./packages/session-query/*/src", - "./packages/session-registry/*/src", "./packages/session-title/*/src", "./packages/telemetry/*/src", "./packages/acp/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index b0121f2a0a..d46a167eb5 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -181,9 +181,6 @@ { "path": "./packages/lsp/lsp" }, { "path": "./packages/lsp/lsp-local" }, { "path": "./packages/lsp/tool-lsp" }, - { "path": "./packages/session-registry/session-registry" }, - { "path": "./packages/session-registry/session-registry-file" }, - { "path": "./packages/session-registry/session-registry-live" }, { "path": "./apps/cli" } ] } From 030b044350c5317856d0c4bffb5e96eb69de4733 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 16:29:55 +0800 Subject: [PATCH 14/77] fix(cli): reject leaked config replacement flags --- apps/cli/src/args.ts | 6 +++--- apps/cli/tests/args.spec.ts | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 5cdf9d20d4..56827f2764 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -226,9 +226,9 @@ Examples: // the subcommand into `program.opts()`. `meta` accepts only `--resume`, so // a leaked `--config`/`-p` is a mistyped invocation that must fail loud // rather than silently be dropped. - const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>() - if (parent.config !== undefined || parent.prompt !== undefined) { - program.error('error: meta takes neither --config nor -p/--prompt') + const parent = program.opts<{ config?: string; configReplace?: string; prompt?: string; resume?: string }>() + if (parent.config !== undefined || parent.configReplace !== undefined || parent.prompt !== undefined) { + program.error('error: meta takes none of --config, --config-replace, or -p/--prompt') } // Same reason as the default surface: an empty id would start a fresh // session downstream instead of failing the mistyped resume. diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 831a969d1f..9e79e333f2 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -27,6 +27,7 @@ describe('parseDshArgs', () => { it('routes each mode by its shape: default TUI, -p headless, meta and web subcommands', () => { expect(parse([])).toEqual({ mode: 'tui' }) expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' }) + expect(parse(['--config-replace', 'tree.yml'])).toEqual({ mode: 'tui', configReplace: 'tree.yml' }) expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) // `meta` accepts `--resume` but does not redeclare it: a shared option parses @@ -58,6 +59,8 @@ describe('parseDshArgs', () => { expect(exitCode(['--resume='])).toBe(1) expect(exitCode(['-p', ''])).toBe(1) expect(exitCode(['-p', 'x', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['-p', 'x', '--config-replace', 'tree.yml'])).toBe(1) + expect(exitCode(['--config', 'c.yml', '--config-replace', 'tree.yml'])).toBe(1) expect(exitCode(['-p', 'x', '--resume', 's'])).toBe(1) expect(exitCode(['--bogus'])).toBe(1) expect(exitCode(['bogus-positional'])).toBe(1) @@ -66,17 +69,20 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '-p', 'task'])).toBe(1) expect(exitCode(['web', '--resume', 's'])).toBe(1) expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1) + expect(exitCode(['--config-replace', 'tree.yml', 'web'])).toBe(1) // Same rule for credential setup: it shares no option with the default // surface, so a leaked flag is a typo, not something to ignore. // `meta` fixes its own config tree and is interactive, so --config/-p are // rejected; an empty id is swallowed downstream exactly as above. expect(exitCode(['meta', '--resume='])).toBe(1) expect(exitCode(['meta', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['meta', '--config-replace', 'tree.yml'])).toBe(1) expect(exitCode(['meta', '-p', 'task'])).toBe(1) // `migrate`/`upgrade` take no options: any leaked default-surface flag is a // mistyped invocation, not a silently-dropped input. expect(exitCode(['migrate', '--resume', 's'])).toBe(1) expect(exitCode(['migrate', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['migrate', '--config-replace', 'tree.yml'])).toBe(1) expect(exitCode(['migrate', '-p', 'task'])).toBe(1) expect(exitCode(['upgrade', '--resume', 's'])).toBe(1) expect(exitCode(['upgrade', '--config', 'c.yml'])).toBe(1) From 33286a0ff578fb32e58f5f299433dbe6875108d5 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 16:30:42 +0800 Subject: [PATCH 15/77] docs(cordis): remove registry API remnants --- packages/cordis/tool-cordis/src/api-catalog.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index d238897cf3..9e5134dd9e 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2251,10 +2251,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionReferenceInput', declaration: 'export interface SessionReferenceInput {\n sessionId: SessionId;\n label?: string;\n}', }, - { - }, - { - }, { name: 'SessionResultFilter', declaration: 'export type SessionResultFilter = {\n kind: \'id\';\n values: readonly SessionId[];\n} | {\n kind: \'cwd\';\n values: readonly (string | null)[];\n} | ({\n kind: \'created-at\';\n} & SessionResultRange) | {\n kind: \'parent\';\n values: readonly (SessionId | null)[];\n} | {\n kind: \'availability\';\n values: readonly SessionAvailability[];\n};', From bee0a77aadb5cde07312c37243f5200bddf7ac12 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 16:31:21 +0800 Subject: [PATCH 16/77] chore: refresh generated review records --- .../2026-07-28-dsh-native-typescript-source-launch.i18n.yaml | 4 ++-- .../testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 ++-- apps/cli/README.i18n.yaml | 4 ++-- docs/development.i18n.yaml | 4 ++-- packages/cordis/tool-cordis/src/api-catalog.ts | 4 ---- 5 files changed, 8 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml index fae9bf86b2..e10d181359 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md -2026-07-28-dsh-native-typescript-source-launch.md: 3fa6ee3711ab5a44391e5c261cff1e7c05fa495a -2026-07-28-dsh-native-typescript-source-launch.zh.md: b749687b3a8c0f0fb92826dabbcedffa21e433f8 +2026-07-28-dsh-native-typescript-source-launch.md: e128ae1a39c6a3838d948b40936dca6e30d324e1 +2026-07-28-dsh-native-typescript-source-launch.zh.md: 5ff1d975f8012e7d831bc3ecf6e5c53a9d43151f diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 0863874748..52f8014d7d 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: 37e2bccddb3725073ebb38377c2cd7464b418012 -2026-07-24-web-gui-browser-e2e-lane.zh.md: f2bbdbb028a3e35831298fea4d5a0f3dec410b82 +2026-07-24-web-gui-browser-e2e-lane.md: 04240456d1df4eab79372de0ebd900f5831cd819 +2026-07-24-web-gui-browser-e2e-lane.zh.md: 6be7056b3d46be875b74c56d93045a37fd3d2c08 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index a38bcc2eaa..72fe6eff6f 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 5c2b3eb77f2606928520c937d96d8f728afb062b -README.zh.md: 927934d7265b8a81518d770e57f2b21249b67e56 +README.md: 2c1d373b39ecd188dde42d24800a1602f39da582 +README.zh.md: b4c1e8a913e3efbac828b22568abe85ee0f7ab37 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index b6cd7c4170..302d371e93 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: 53e545aabb074d4ad4c5ad62722c88f9ae4c06ae -development.zh.md: a5c95f0fb7e16b7678b5165856b0bc8e2e274d8c +development.md: 263056e66d705ecd7a9af862757daaecc44df847 +development.zh.md: f6c127dc3f8ddb359a2d9fc9b278f15600e6fb45 diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 9e5134dd9e..7d9b087b8f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1543,10 +1543,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'BashSandboxInfo', declaration: 'export interface BashSandboxInfo {\n mode: SandboxMode;\n denied: boolean;\n enforcement?: SandboxEnforcement;\n runnerFailed?: boolean;\n}', }, - { - name: 'BootId', - declaration: 'export type BootId = Branded<\'BootId\'>;', - }, { name: 'Branded', declaration: 'export type Branded = string & {\n readonly [BRAND]: B;\n};', From 857a4941beb890dd8a893f5d6a2ed86a21e6423d Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 17:34:50 +0800 Subject: [PATCH 17/77] fix(cli): restore shipped surface capabilities --- apps/cli/base.cordis.yml | 19 +++++++--- apps/cli/composition.md | 12 +++++-- apps/cli/package.json | 9 ++++- apps/cli/src/args.ts | 19 ++-------- apps/cli/src/tui.ts | 12 ++++--- apps/cli/tests/args.spec.ts | 11 ++---- apps/cli/tui.cordis.yml | 5 ++- apps/cli/web.cordis.yml | 48 ++++++++++++++++++++++++++ examples/acp-agent/composition.md | 2 +- examples/headless-agent/composition.md | 2 +- pnpm-lock.yaml | 27 +++++++++++++-- scripts/gen-doc-graphs.ts | 14 ++++---- scripts/verify-cordis-config.ts | 7 +++- 13 files changed, 133 insertions(+), 54 deletions(-) diff --git a/apps/cli/base.cordis.yml b/apps/cli/base.cordis.yml index 40bb3fe946..cb1fd7925d 100644 --- a/apps/cli/base.cordis.yml +++ b/apps/cli/base.cordis.yml @@ -8,8 +8,8 @@ # A patch replaces the targeted row's whole `config` rather than merging into # it, so a row whose value differs per surface does NOT live here: it belongs to # each overlay, keeping any single row down to one overlay layer plus the user's. -# That is why `agent-loop`, `system-prompt`, `tools`, `fs-local`, and -# `llm-deepseek` thinking defaults are absent below. +# Rows with surface-specific values appear below only with shared plugin identity +# and neutral defaults; each overlay restates the complete surface configuration. # # Row order carries no load semantics (activation is service-availability # driven); the grouping is for readers. @@ -51,9 +51,20 @@ - id: llm-retry name: '@deepseek-ai/dsh-llm-retry' +- id: llm-pi-ai + name: '@deepseek-ai/dsh-llm-pi-ai' + config: + providers: + - provider: openai + apiKey: !!js process.env.OPENAI_API_KEY + baseURL: !!js process.env.OPENAI_BASE_URL + - provider: anthropic + apiKey: !!js process.env.ANTHROPIC_API_KEY + baseURL: !!js process.env.ANTHROPIC_BASE_URL + # The session store root is the launcher's policy, not a plugin's: `dsh` shares -# one store under the Harness home across every cwd, so `/resume` and -# `/resume` spans workspaces. Without a launcher the project-local fallback keeps +# one store under the Harness home across every cwd, so `/resume` spans +# workspaces. Without a launcher the project-local fallback keeps # an embedder's sessions beside its project. - id: session-persistence-jsonl name: '@deepseek-ai/dsh-session-persistence-jsonl' diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 47d14ce88d..9c0d5b5f06 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -7,7 +7,7 @@ The TUI agent combines the real DeepSeek adapter, coding tools, compaction, suba ```mermaid flowchart LR - cfg["apps/cli (dsh)
cordis.yml"] + cfg["apps/cli (dsh)
base.cordis.yml + tui.cordis.yml"] plugin_tui_timer["timer
@cordisjs/plugin-timer"] cfg --> plugin_tui_timer plugin_tui_llm["llm
@deepseek-ai/dsh-llm"] @@ -26,6 +26,8 @@ flowchart LR cfg --> plugin_tui_tasks plugin_tui_llm_retry["llm-retry
@deepseek-ai/dsh-llm-retry"] cfg --> plugin_tui_llm_retry + plugin_tui_llm_pi_ai["llm-pi-ai
@deepseek-ai/dsh-llm-pi-ai"] + cfg --> plugin_tui_llm_pi_ai plugin_tui_session_persistence_jsonl["session-persistence-jsonl
@deepseek-ai/dsh-session-persistence-jsonl"] cfg --> plugin_tui_session_persistence_jsonl plugin_tui_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] @@ -94,6 +96,8 @@ flowchart LR cfg --> plugin_tui_fs_local plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_tui_llm_deepseek + plugin_tui_tools["tools
@deepseek-ai/dsh-tool-ask-user"] + cfg --> plugin_tui_tools ``` | Plugin id | Package / module | @@ -107,6 +111,7 @@ flowchart LR | `agent` | `@deepseek-ai/dsh-agent` | | `tasks` | `@deepseek-ai/dsh-tasks-local` | | `llm-retry` | `@deepseek-ai/dsh-llm-retry` | +| `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` | | `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` | | `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash-local` | `@deepseek-ai/dsh-bash-local` | @@ -141,7 +146,8 @@ flowchart LR | `agent-loop` | `@deepseek-ai/dsh-agent-loop` | | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `tools` | `@deepseek-ai/dsh-tool-ask-user` | -Source config: [`apps/cli/base.cordis.yml`](base.cordis.yml). +Source configs: [`apps/cli/base.cordis.yml`](base.cordis.yml), [`apps/cli/tui.cordis.yml`](tui.cordis.yml). -Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. +Maintenance mode: hybrid: the leaf plugin list is parsed from its shipped config files; app package expansion is curated from package source. diff --git a/apps/cli/package.json b/apps/cli/package.json index ddbef2cd03..87cbf29060 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -22,6 +22,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -33,6 +34,7 @@ "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-model": "workspace:^", "@deepseek-ai/dsh-client-ui-models": "workspace:^", + "@deepseek-ai/dsh-client-ui-permission": "workspace:^", "@deepseek-ai/dsh-client-ui-plan": "workspace:^", "@deepseek-ai/dsh-client-ui-question": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", @@ -52,10 +54,11 @@ "@deepseek-ai/dsh-frontend": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", - "@deepseek-ai/dsh-helper": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -63,7 +66,10 @@ "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-sandbox-local": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", @@ -102,6 +108,7 @@ "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-tui": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 56827f2764..e470af1299 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -213,27 +213,12 @@ Examples: resolved = resolveWeb(options) }) - // `--resume` is NOT redeclared here: an option a subcommand shares with its - // parent parses into `program.opts()` and leaves the subcommand's own options - // empty, so redeclaring it would silently drop the id. Commander therefore - // omits it from this subcommand's option list, hence the trailing help text. program .command('meta') .description('work on the dsh source that runs this command, from any directory') - .addHelpText('after', '\nAccepts --resume to resume a persisted session from this checkout.\n') .action(() => { - // Commander parses the parent (default-surface) options on either side of - // the subcommand into `program.opts()`. `meta` accepts only `--resume`, so - // a leaked `--config`/`-p` is a mistyped invocation that must fail loud - // rather than silently be dropped. - const parent = program.opts<{ config?: string; configReplace?: string; prompt?: string; resume?: string }>() - if (parent.config !== undefined || parent.configReplace !== undefined || parent.prompt !== undefined) { - program.error('error: meta takes none of --config, --config-replace, or -p/--prompt') - } - // Same reason as the default surface: an empty id would start a fresh - // session downstream instead of failing the mistyped resume. - if (parent.resume === '') program.error('error: --resume needs a session id') - resolved = { mode: 'meta', ...parent.resume !== undefined && { resume: parent.resume } } + rejectParentOptions('meta') + resolved = { mode: 'meta' } }) try { diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index f0ef5ddc02..e9d108bfb0 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -18,7 +18,7 @@ */ import { randomUUID } from 'node:crypto' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { addHarnessSourceSection, @@ -155,6 +155,8 @@ export async function runTui( // selected session may belong to another workspace, so the handoff also enters // that directory. The host is offered only when Node exposes `process.execve` // and knows its own entry. + const resolvedConfig = config === undefined ? undefined : resolve(config) + const resolvedConfigReplace = configReplace === undefined ? undefined : resolve(configReplace) const entry = process.argv[1] const execve = process.execve?.bind(process) const app: { current?: Context } = {} @@ -172,8 +174,8 @@ export async function runTui( `--resume=${sessionId}`, // Both config flags must survive the handoff: resuming into a different // tree than the session was created in would silently change the agent. - ...config !== undefined ? ['--config', config] : [], - ...configReplace !== undefined ? ['--config-replace', configReplace] : [], + ...resolvedConfig !== undefined ? ['--config', resolvedConfig] : [], + ...resolvedConfigReplace !== undefined ? ['--config-replace', resolvedConfigReplace] : [], ] // Mint the fresh id here rather than in the app bundle: the exit line names // the session to resume, so the launcher must know it before the tree boots. @@ -223,11 +225,11 @@ export async function runTui( ...loadOverlayPatches(NAME, TUI_OVERLAY), ...config === undefined ? loadPersonalPatches(NAME) ?? [] - : loadOverlayPatches(NAME, resolveConfigPath(config, undefined)), + : loadOverlayPatches(NAME, resolveConfigPath(resolve(config), undefined)), ] const ctx = await boot( NAME, - replaceTree ? resolveConfigPath(configReplace, undefined) : BASE_CONFIG, + replaceTree ? resolveConfigPath(resolve(configReplace), undefined) : BASE_CONFIG, patches, (hostCtx) => { // The launcher owns session identity and the exit line: a config-mounted diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 9e79e333f2..7eea63b68d 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -30,12 +30,7 @@ describe('parseDshArgs', () => { expect(parse(['--config-replace', 'tree.yml'])).toEqual({ mode: 'tui', configReplace: 'tree.yml' }) expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) - // `meta` accepts `--resume` but does not redeclare it: a shared option parses - // into program.opts() on either side of the subcommand, and redeclaring it - // would leave the subcommand's own options empty and drop the id. expect(parse(['meta'])).toEqual({ mode: 'meta' }) - expect(parse(['meta', '--resume', 'sess'])).toEqual({ mode: 'meta', resume: 'sess' }) - expect(parse(['--resume', 'sess', 'meta'])).toEqual({ mode: 'meta', resume: 'sess' }) // Credential setup is option-free: it writes the Harness-home .env, so // there is nothing for a flag to select. // Bare `web` carries no host/port: the shipped cordis.yml owns the default. @@ -72,9 +67,9 @@ describe('parseDshArgs', () => { expect(exitCode(['--config-replace', 'tree.yml', 'web'])).toBe(1) // Same rule for credential setup: it shares no option with the default // surface, so a leaked flag is a typo, not something to ignore. - // `meta` fixes its own config tree and is interactive, so --config/-p are - // rejected; an empty id is swallowed downstream exactly as above. - expect(exitCode(['meta', '--resume='])).toBe(1) + // `meta` fixes its own config tree and always starts fresh, so every + // default-surface option is rejected. + expect(exitCode(['meta', '--resume', 's'])).toBe(1) expect(exitCode(['meta', '--config', 'c.yml'])).toBe(1) expect(exitCode(['meta', '--config-replace', 'tree.yml'])).toBe(1) expect(exitCode(['meta', '-p', 'task'])).toBe(1) diff --git a/apps/cli/tui.cordis.yml b/apps/cli/tui.cordis.yml index 13b8c8930d..82f5e7aa9e 100644 --- a/apps/cli/tui.cordis.yml +++ b/apps/cli/tui.cordis.yml @@ -94,9 +94,8 @@ - id: session-reference name: '@deepseek-ai/dsh-session-reference' - # Refuses write/edit inside the dsh checkout this launcher runs from, on that - # checkout's own branch, until the session loads dsh-customize. Inert - # everywhere else, so an ordinary project sees no change. + # Compacts oversized tool results before the broader conversation compactor + # runs, preserving the model-visible result within the configured budget. - id: tool-result-prune name: '@deepseek-ai/dsh-compact-tool-result-prune' diff --git a/apps/cli/web.cordis.yml b/apps/cli/web.cordis.yml index 9cbd73a6f6..6a2d5df9e5 100644 --- a/apps/cli/web.cordis.yml +++ b/apps/cli/web.cordis.yml @@ -31,7 +31,49 @@ apiKey: !!js process.env.DEEPSEEK_API_KEY baseURL: !!js process.env.DEEPSEEK_BASE_URL +# The web surface replaces the unrestricted local executors with the shared +# sandbox policy. Its default preserves the previous unrestricted behavior; +# DSH_PERMISSION_MODE and the browser permission picker can confine a session. +- insert: + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + + - id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: !!js process.env.DSH_PERMISSION_MODE ?? 'danger-full-access' + workspaceRoot: !!js process.cwd() + + - id: bash-sandbox + name: '@deepseek-ai/dsh-bash-sandbox' + + - id: approval + name: '@deepseek-ai/dsh-user-approval' + config: + policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'danger-full-access') === 'danger-full-access' ? 'never' : 'ask'" + + - id: permission + name: '@deepseek-ai/dsh-permission' + config: + presets: + read-only: + sandbox: read-only + approval: ask + workspace-write: + sandbox: workspace-write + approval: ask + danger-full-access: + sandbox: danger-full-access + approval: never + + - id: fs-sandbox + name: '@deepseek-ai/dsh-fs-sandbox' + +- id: bash-local + remove: true + - id: fs-local + remove: true # ── web-only host rows, the transport layer, and the browser roster ───────── @@ -72,6 +114,9 @@ # The API gateway: the transport-agnostic dispatch face every client shape # shares. provider/model are the host default routing — the profile json's # mapping target (user config overrides these engineering defaults). + - id: directory-picker + name: '@deepseek-ai/dsh-host-directory-picker-browse' + - id: api-gateway name: '@deepseek-ai/dsh-host-apiproxy' config: @@ -156,6 +201,9 @@ - id: ui-model name: '@deepseek-ai/dsh-client-ui-model' + - id: ui-permission + name: '@deepseek-ai/dsh-client-ui-permission' + # Plan control: the composer plan seat over the plan projection + /plan channel. - id: ui-plan name: '@deepseek-ai/dsh-client-ui-plan' diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index b4a3236920..6cd896ca31 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -94,4 +94,4 @@ flowchart LR Source config: [`examples/acp-agent/cordis.yml`](cordis.yml). -Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. +Maintenance mode: hybrid: the leaf plugin list is parsed from its shipped config files; app package expansion is curated from package source. diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index 53a01260e1..7c55a80fdd 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -76,4 +76,4 @@ flowchart LR Source config: [`examples/headless-agent/cordis.yml`](cordis.yml). -Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. +Maintenance mode: hybrid: the leaf plugin list is parsed from its shipped config files; app package expansion is curated from package source. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e54ba3a05..26d195da58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,6 +134,9 @@ importers: '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../packages/bash/bash-local + '@deepseek-ai/dsh-bash-sandbox': + specifier: workspace:^ + version: link:../../packages/bash/bash-sandbox '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../packages/client/connection @@ -167,6 +170,9 @@ importers: '@deepseek-ai/dsh-client-ui-models': specifier: workspace:^ version: link:../../packages/client/ui-models + '@deepseek-ai/dsh-client-ui-permission': + specifier: workspace:^ + version: link:../../packages/client/ui-permission '@deepseek-ai/dsh-client-ui-plan': specifier: workspace:^ version: link:../../packages/client/ui-plan @@ -224,18 +230,21 @@ importers: '@deepseek-ai/dsh-fs-policy': specifier: workspace:^ version: link:../../packages/fs/fs-policy + '@deepseek-ai/dsh-fs-sandbox': + specifier: workspace:^ + version: link:../../packages/fs/fs-sandbox '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../packages/goal/goal '@deepseek-ai/dsh-goal-session': specifier: workspace:^ version: link:../../packages/goal/goal-session - '@deepseek-ai/dsh-helper': - specifier: workspace:^ - version: link:../../packages/sdk/helper '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../packages/host/apiproxy + '@deepseek-ai/dsh-host-directory-picker-browse': + specifier: workspace:^ + version: link:../../packages/host/directory-picker-browse '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../packages/host/webserver @@ -257,9 +266,18 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../packages/util/paths + '@deepseek-ai/dsh-permission': + specifier: workspace:^ + version: link:../../packages/ui/permission '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../packages/sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../packages/sandbox/sandbox-policy '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../packages/core/scope @@ -374,6 +392,9 @@ importers: '@deepseek-ai/dsh-tui': specifier: workspace:^ version: link:../../packages/ui/tui + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../packages/ui/user-approval '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../../packages/ui/user-interaction diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 73ba05f748..54cdc11a50 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -597,7 +597,7 @@ const APP_EXAMPLES = [ rel: 'apps/cli/composition.md', title: 'dsh TUI Composition', label: 'apps/cli (dsh)', - config: 'apps/cli/base.cordis.yml', + configs: ['apps/cli/base.cordis.yml', 'apps/cli/tui.cordis.yml'], summary: 'The TUI agent combines the real DeepSeek adapter, coding tools, compaction, subagents, and workflows with the full-screen terminal app package.', }, { @@ -605,7 +605,7 @@ const APP_EXAMPLES = [ rel: 'examples/headless-agent/composition.md', title: 'Headless Agent App Composition', label: 'examples/headless-agent', - config: 'examples/headless-agent/cordis.yml', + configs: ['examples/headless-agent/cordis.yml'], summary: 'The headless demo combines the real DeepSeek adapter and coding capabilities with the one-shot app package, format-pure stdout, and one fresh persisted top-level session.', }, { @@ -613,7 +613,7 @@ const APP_EXAMPLES = [ rel: 'examples/acp-agent/composition.md', title: 'ACP Automation App Composition', label: 'examples/acp-agent', - config: 'examples/acp-agent/cordis.yml', + configs: ['examples/acp-agent/cordis.yml'], summary: 'The ACP demo exposes fresh baseline-prompt agent sessions to programmatic clients over JSON-RPC stdio, with no stdout logger, human UI, or pre-created agent.', }, ] @@ -639,15 +639,15 @@ function renderAppExpansion(lines: string[], appNode: string, pluginName: string } function renderAppComposition(example: AppExample): string { - const plugins = parseExampleCordis(example.config) - const maintenance = 'hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source' + const plugins = example.configs.flatMap(parseExampleCordis) + const maintenance = 'hybrid: the leaf plugin list is parsed from its shipped config files; app package expansion is curated from package source' const lines = generatedHeader(example.title) lines.push( example.summary, '', '```mermaid', 'flowchart LR', - ` cfg["${escLabel(example.label)}
cordis.yml"]`, + ` cfg["${escLabel(example.label)}
${escLabel(example.configs.map(config => config.split('/').at(-1)).join(' + '))}"]`, ) for (const plugin of plugins) { const pluginNode = nodeId(`plugin_${example.id}`, plugin.id) @@ -664,7 +664,7 @@ function renderAppComposition(example: AppExample): string { '| --- | --- |', ...plugins.map(plugin => `| \`${plugin.id}\` | \`${plugin.name}\` |`), '', - `Source config: [\`${example.config}\`](${linkFromDoc(example.rel, example.config)}).`, + `Source config${example.configs.length === 1 ? '' : 's'}: ${example.configs.map(config => `[\`${config}\`](${linkFromDoc(example.rel, config)})`).join(', ')}.`, ) lines.push('', ...maintenanceFooter(maintenance)) return lines.join('\n') diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index b0f7b06eb6..9a188a5c75 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -78,6 +78,11 @@ function validateEntry(value: unknown, file: string, path: string): void { validateEntry(value.config[index], file, `${path}.config[${index}]`) } } + if (isUnknownArray(value.insert)) { + for (let index = 0; index < value.insert.length; index++) { + validateEntry(value.insert[index], file, `${path}.insert[${index}]`) + } + } if (value.name !== '@cordisjs/plugin-include') return const config = value.config if (!isRecord(config) || !isUnknownArray(config.patches)) return @@ -124,7 +129,7 @@ function validateExampleResolution(): string[] { function validateAppResolution(): string[] { const dependencies = readManifest('apps/cli/package.json').dependencies ?? {} - const references = pluginReferences.filter(reference => reference.file === 'apps/cli/base.cordis.yml') + const references = pluginReferences.filter(reference => reference.file.startsWith('apps/cli/')) return missingPluginDependencies(references, dependencies, 'apps/cli/package.json') } From d44fce8a39ddcbf5ca6cde7390537591e0f37cd4 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 17:59:28 +0800 Subject: [PATCH 18/77] fix(cli): keep meta as a fresh-session command --- apps/cli/src/bin.ts | 2 +- apps/cli/src/tui.ts | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index c5003cf795..619d0dcd58 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -45,7 +45,7 @@ switch (invocation.mode) { } case 'meta': { const { runMeta } = await import('./tui.ts') - await runMeta(invocation.resume) + await runMeta() break } case 'migrate': diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index e9d108bfb0..f4b4b18f40 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -82,12 +82,9 @@ export function launcherSessionsRoot(): string { /** * Run the interactive TUI with this harness checkout as the workspace * (`dsh meta`), whatever directory it was launched from. - * @param resumeSessionId - a persisted session id to resume, or `undefined`; - * see {@link runTui}. Meta-mode sessions live under the checkout, so an id from - * an ordinary `dsh` run in another directory is not found here. */ -export async function runMeta(resumeSessionId: string | undefined): Promise { - return runTui(undefined, resumeSessionId, SOURCE_ROOT) +export async function runMeta(): Promise { + return runTui(undefined, undefined, SOURCE_ROOT) } /** From fdd113ce5ecfb04d72c40816a8037ca7b7a9d6cc Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 18:18:10 +0800 Subject: [PATCH 19/77] fix(tui): read mounted session query during sibling activation --- packages/ui/tui/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 78c3505db9..a860400e2b 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -854,7 +854,7 @@ export function createTuiChat( overlayManager, // Optional and independently mounted: read at each use so config row order // cannot decide whether /resume works. - sessionQuery: () => ctx.get('sessionQuery'), + sessionQuery: () => ctx.get('sessionQuery', false), ui, editor, appendNotice, From 984830c94076f2d405f0bc47761ee78bc16fd8b1 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 18:21:14 +0800 Subject: [PATCH 20/77] fix(tui): isolate each process query index --- apps/cli/src/tui.ts | 12 +++++++----- apps/cli/tsconfig.json | 3 +++ apps/cli/tui.cordis.yml | 7 +++---- .../session-query/session-query-sqlite/src/index.ts | 10 ++++++++++ 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index f4b4b18f40..6964ac9753 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -19,6 +19,7 @@ import { randomUUID } from 'node:crypto' import { join, resolve } from 'node:path' +import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import { addHarnessSourceSection, @@ -31,6 +32,7 @@ import { } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome, resolveSessionsRoot } from '@deepseek-ai/dsh-paths' import { SessionId } from '@deepseek-ai/dsh-session' +import { SESSION_QUERY_SQLITE_PATH_KEY } from '@deepseek-ai/dsh-session-query-sqlite' import { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop' import type { Context } from 'cordis' import { @@ -57,8 +59,8 @@ const TUI_OVERLAY = fileURLToPath(new URL('../tui.cordis.yml', import.meta.url)) // session identity by this config id. const MAIN_AGENT_ID = 'main' -/** Filename of the derived `/resume` index, kept beside the session logs. */ -const SESSION_QUERY_DB = 'session-query.db' +/** Per-process filename of the disposable `/resume` index. */ +const SESSION_QUERY_DB = `session-query-${String(process.pid)}-${randomUUID()}.db` // The harness checkout root: three hops up from apps/cli/{src,lib}, resolved // from this bin's location so it holds however `dsh` is launched (a PATH @@ -242,9 +244,9 @@ export async function runTui( // same id, so a personal overlay repointing the model route cannot drop // the session identity or desynchronise the two. hostCtx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, { [MAIN_AGENT_ID]: identity }) - // The launcher owns the session store location, so it also owns the - // derived index path that must sit beside those logs. - hostCtx.provide('launcherSessionQueryPath', join(launcherSessionsRoot(), SESSION_QUERY_DB)) + // The query database is a disposable derived index with single-process + // ownership. Keep it process-local while it indexes the shared logs. + hostCtx.provide(SESSION_QUERY_SQLITE_PATH_KEY, join(tmpdir(), SESSION_QUERY_DB)) if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost) // Seed the first turn only for a fresh session, so resuming never // re-invokes the skill. diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index ce43c0f31c..a13d015a7a 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../packages/util/paths" }, + { + "path": "../../packages/session-query/session-query-sqlite" + }, { "path": "../../packages/client/connection" }, diff --git a/apps/cli/tui.cordis.yml b/apps/cli/tui.cordis.yml index 82f5e7aa9e..f71be09e85 100644 --- a/apps/cli/tui.cordis.yml +++ b/apps/cli/tui.cordis.yml @@ -82,10 +82,9 @@ - id: session-checkpoint-policy name: '@deepseek-ai/dsh-session-checkpoint-policy' - # The derived query index behind `/resume`. The launcher owns the session - # store location, so it provides the resolved index path on the boot context - # (`launcherSessionQueryPath`); the index and the logs it indexes therefore - # cannot diverge. The project-local fallback applies when no launcher sets it. + # The derived query index behind `/resume`. The launcher provides a unique + # process-local path because this SQLite backend has one writer owner; the + # project-local fallback applies when no launcher sets the typed slot. - id: session-query-sqlite name: '@deepseek-ai/dsh-session-query-sqlite' config: diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 0c073ccea5..e76e0a5a43 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -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. */ From 9bbaea45fd014e861e07dd2dbac57267f0987721 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 18:24:48 +0800 Subject: [PATCH 21/77] docs(cli): explain config overlay modes --- apps/cli/README.i18n.yaml | 4 ++-- apps/cli/README.md | 6 +++--- apps/cli/README.zh.md | 6 +++--- docs/config-catalog.md | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 72fe6eff6f..6ef7959159 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 2c1d373b39ecd188dde42d24800a1602f39da582 -README.zh.md: b4c1e8a913e3efbac828b22568abe85ee0f7ab37 +README.md: 2589462dfb5fc600b0e480a5a41c32860bf6837d +README.zh.md: 3b5e5319b76d9b18b2719cc8e943faaf398c51af diff --git a/apps/cli/README.md b/apps/cli/README.md index 2c1d373b39..2589462dfb 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -7,18 +7,18 @@ Argv is parsed once through a [Commander](https://github.com/tj/commander.js) ad The TUI surface: -- boots the shipped default config (`apps/cli/base.cordis.yml`), or the tree named by `--config ` (the demo/test escape for booting an alternate example tree), through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); +- boots `base.cordis.yml` plus `tui.cordis.yml` through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); `--config ` applies a patch-list overlay instead of the personal overlay, while `--config-replace ` boots that file as the complete tree; - resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume invocation; runtimes without process replacement leave the session running and say so. This CLI owns session identity and the exit line rather than the config: it mints or selects the `main` session id and provides it, plus the exact command that reproduces this invocation, on the boot context ([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) and `TUI_GOODBYE_MESSAGE_KEY`). No `cordis.yml` key can drop resume, and a missing or unreadable id fails loud instead of creating a fresh session; - treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh meta` is the sole exception, below); - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. -`dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after both `.env` layers are loaded, so environment precedence is unchanged while the session cwd, the `./.sessions` persistence root, and the HMR watch root all move together. It accepts only `--resume `; `--config` (which would boot a foreign tree) and `-p` (which is not interactive) fail loud. Because meta sessions live under the checkout, `--resume` here sees only other meta sessions, and both the in-place handoff and the printed exit line reproduce the mode as `dsh meta --resume `, so a copied command resumes the right session from any directory. +`dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after both `.env` layers are loaded, so environment precedence is unchanged while the session cwd and HMR watch root move together. Meta always starts a fresh session and accepts no default-surface options; use ordinary `dsh --resume ` to resume a persisted session. `dsh migrate` and `dsh upgrade` are guided fresh-session entries over the default TUI surface: each mints a fresh session in the invoking directory and seeds its first turn with a bundled skill (`dsh-migrate` for migrating from another coding agent — opencode, pi, Claude Code, Codex; `dsh-upgrade` for upgrading this checkout), exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection. -The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). +The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`; `dsh web --config ` adds an overlay after the web surface defaults. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index b4c1e8a913..3b5e5319b7 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -7,18 +7,18 @@ Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([` TUI 界面: -- 启动已交付的默认配置(`apps/cli/base.cordis.yml`),或由 `--config ` 指定的树(演示/测试用于启动其他示例树的逃生口),并通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 完成启动; +- 通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 启动 `base.cordis.yml` 与 `tui.cordis.yml`;`--config ` 应用一个补丁列表覆盖并替代个人覆盖,而 `--config-replace ` 将指定文件作为完整配置树启动; - 使用 `dsh --resume ` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的恢复调用替换进程;不支持进程替换的运行时会让会话继续运行并给出提示。会话身份与退出行由本 CLI 拥有,而非由配置指定:它创建或选定 `main` 会话 id,并把该 id 以及可复现本次调用的确切命令一起提供到启动上下文([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) 与 `TUI_GOODBYE_MESSAGE_KEY`)。任何 `cordis.yml` 键都无法移除恢复能力;缺失或无法读取的 id 会明确报错,而不会创建新会话; - 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文); - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 -`dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在两层 `.env` 都加载之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd、`./.sessions` 持久化根目录与 HMR 监视根目录会一并移动。它只接受 `--resume `;`--config`(会启动其他配置树)和 `-p`(非交互)都会明确报错。由于 meta 会话位于该 checkout 之下,此处的 `--resume` 只能看到其他 meta 会话;原地移交与打印的退出行都会以 `dsh meta --resume ` 复现该 mode,因此复制的命令在任何目录下都能恢复到正确的会话。 +`dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在两层 `.env` 都加载之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume `。 `dsh migrate` 与 `dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:各自在调用目录中创建一个全新会话,并以一个内置 skill 播种其首轮(`dsh-migrate` 用于从其他编码 agent 迁移——opencode、pi、Claude Code、Codex;`dsh-upgrade` 用于升级本 checkout),效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。 -Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 +Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`;`dsh web --config ` 会在 Web 界面默认值之后追加一个覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 49e23c662e..2d4dab9d78 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1133,7 +1133,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' Depends on: [`SessionQueryConfig`](../packages/session-query/session-query/src/index.ts) -Source: [`packages/session-query/session-query-sqlite/src/index.ts:76`](../packages/session-query/session-query-sqlite/src/index.ts) +Source: [`packages/session-query/session-query-sqlite/src/index.ts:86`](../packages/session-query/session-query-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-reference` From fa1ca61cfd176c5c44c20cf87cea56d477e61613 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 18:27:37 +0800 Subject: [PATCH 22/77] fix(hygiene): remove stale session registry fragment --- knip.json | 9 --------- 1 file changed, 9 deletions(-) diff --git a/knip.json b/knip.json index b79a4d8977..a492f5a895 100644 --- a/knip.json +++ b/knip.json @@ -287,15 +287,6 @@ "src/**/*.ts", "tests/**/*.ts" ] - }, - "entry": [ - "tests/**/*.spec.ts", - "tests/fixtures/register-once.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] }, "packages/session-query/session-query-sqlite": { "entry": [ From 291d6d27729e502dc85dfaed93dac6dda238f130 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 18:31:32 +0800 Subject: [PATCH 23/77] docs: refresh module graph after query dependency --- docs/module-graph.md | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index f20dd1c5fd..c30cee6162 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -180,7 +180,6 @@ flowchart TD pkg_agent_spine_demo["agent-spine-demo"] pkg_cli_demo["cli-demo"] pkg_jsonrpc_demo["jsonrpc-demo"] - pkg_tui_demo["tui-demo"] end subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] @@ -935,24 +934,6 @@ flowchart TD pkg_cli_demo --> pkg_session_persistence_jsonl pkg_cli_demo --> pkg_tools pkg_cli_demo --> pkg_workspace_context - pkg_tui_demo --> pkg_agent - pkg_tui_demo --> pkg_agent_loop - pkg_tui_demo --> pkg_agent_spine_demo - pkg_tui_demo --> pkg_command_goal - pkg_tui_demo --> pkg_commands - pkg_tui_demo --> pkg_invariants - pkg_tui_demo --> pkg_llm - pkg_tui_demo --> pkg_session - pkg_tui_demo --> pkg_session_checkpoint_policy - pkg_tui_demo --> pkg_session_persistence_jsonl - pkg_tui_demo --> pkg_session_query - pkg_tui_demo --> pkg_session_query_sqlite - pkg_tui_demo --> pkg_session_reference - pkg_tui_demo --> pkg_tool_ask_user - pkg_tui_demo --> pkg_tools - pkg_tui_demo --> pkg_tui - pkg_tui_demo --> pkg_user_interaction - pkg_tui_demo --> pkg_workspace_context pkg_sdk_client --> pkg_invariants pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol From 9970f609691d570b637ce396773a02733643962e Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:12:36 +0800 Subject: [PATCH 24/77] test: cover overlays and refresh translation snapshot --- packages/ui/app-boot/tests/app-boot.spec.ts | 21 ++++++++++++++++++- .../request-response.expected.json | 4 ++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index db425c301a..3da19e67c2 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -6,7 +6,7 @@ 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, + 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() diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 5cfb198296..093a42c479 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -16,11 +16,11 @@ }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths must be integrated or removed explicitly.\n\nBefore enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files.\n\nAfter moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat 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.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `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 |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `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 |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost 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:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- 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.\n\nStatic 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).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe 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.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`. When Git seeds a new worktree with another registered worktree's marker-backed hook path, the wrapper replaces that copied value with the new worktree's own path; command-scoped and other worktree-scoped paths must be integrated or removed explicitly.\n\nBefore enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files.\n\nAfter moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat 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.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `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 |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `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 |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost 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:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- 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.\n\nStatic 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).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe 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.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`;命令作用域和 worktree 作用域的自定义路径必须显式集成或移除。\n\n启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。\n\n检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 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)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`。当 Git 使用另一个已注册 worktree 中由所有权标记佐证的钩子路径初始化新 worktree 时,包装层会将这个复制值替换为新 worktree 自有的路径;命令作用域和其他 worktree 作用域的路径必须显式集成或移除。\n\n启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。\n\n检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 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)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" }, { "role": "user", From d2c7aaddc73925b7050c2ee3e4604a32494828f7 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:16:31 +0800 Subject: [PATCH 25/77] fix(rebase): restore consolidated documentation state --- examples/README.i18n.yaml | 5 - examples/README.zh.md | 18 +-- examples/code-mode/README.i18n.yaml | 6 - examples/cordis-agent/README.i18n.yaml | 6 - examples/cordis-agent/README.zh.md | 37 ------ examples/tui-agent/README.zh.md | 80 ------------- packages/examples/README.i18n.yaml | 5 - packages/examples/README.zh.md | 17 +-- .../agent-spine-demo/README.i18n.yaml | 5 - .../examples/agent-spine-demo/README.zh.md | 24 ++-- packages/examples/tui-demo/README.i18n.yaml | 6 - packages/examples/tui-demo/README.zh.md | 112 ------------------ packages/todo/README.i18n.yaml | 5 - packages/todo/README.zh.md | 8 +- packages/todo/tool-todo/README.i18n.yaml | 5 - packages/todo/tool-todo/README.zh.md | 32 +++-- packages/ui/README.i18n.yaml | 5 - packages/ui/README.zh.md | 14 +-- 18 files changed, 40 insertions(+), 350 deletions(-) delete mode 100644 examples/cordis-agent/README.i18n.yaml delete mode 100644 examples/cordis-agent/README.zh.md delete mode 100644 examples/tui-agent/README.zh.md delete mode 100644 packages/examples/tui-demo/README.i18n.yaml delete mode 100644 packages/examples/tui-demo/README.zh.md diff --git a/examples/README.i18n.yaml b/examples/README.i18n.yaml index a05f495338..6ac301268c 100644 --- a/examples/README.i18n.yaml +++ b/examples/README.i18n.yaml @@ -2,10 +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 examples/README.md -<<<<<<< HEAD -README.md: 7f12178d1b67f1ebfac6f4f0e31403c54106e98f -README.zh.md: c7c1bf76593661616464558e554d57340d7c03b1 -======= README.md: eb425ad152579ee10bbd54e99c667606fc8a649a README.zh.md: 5f00b4471b516bb78679f09b41dcb1bdbc3ebdbc ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/examples/README.zh.md b/examples/README.zh.md index de91e30217..5f00b4471b 100644 --- a/examples/README.zh.md +++ b/examples/README.zh.md @@ -2,15 +2,11 @@ [English](README.md) | 中文 -<<<<<<< HEAD -展示 harness 如何组装的可运行演示(不是 workspace)。每个示例都是一个 **轻量叶节点**:一份选择可替换后端、加载一个应用包(package)并可添加可选产品工具的 `cordis.yml`。组合和启动粘合代码位于 [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo)、[`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo)、[`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 及它们共享的 [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) 组合包中。没有 `start.ts`;终端 `demo:*` 脚本通过 [`dsh`](../apps/cli/README.md) CLI(命令行界面)启动(该 CLI 挂载 `tui-demo` 组合包),无头/ACP(Agent Client Protocol)脚本则调用 `cli-demo`/`acp-demo` bin。 -======= 展示 harness 如何接线的可运行演示(不是 workspace)。每个示例都是一个 **轻量叶节点**:要么是一份选择可替换后端、加载一个应用包(package)的 `cordis.yml` 配置树,要么是一个 **overlay**——由 `dsh --config` 叠加到交付组合([`apps/cli/base.cordis.yml`](../apps/cli/base.cordis.yml) 加一份 surface overlay)之上的 patch 列表。成组的组合位于 [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo)、[`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 及它们共享的 [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) 组合包中;`dsh` 的各 surface 则改用平铺 config tree。没有 `start.ts`;终端 `demo:*` 脚本通过 [`dsh`](../apps/cli/README.md) CLI(命令行界面)启动,无头/ACP(Agent Client Protocol)脚本则调用 `cli-demo`/`acp-demo` bin。 ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) ## headless-agent -非交互式 agent(智能体)演示:接受一个位置参数形式的任务,在 `@deepseek-ai/dsh-cli-demo` 应用上运行一个完整模型/工具轮次,持久化新会话,打印 `text`、`json` 或 `stream-json`,然后退出。 +非交互式 agent(智能体)演示:接受一个位置任务,在 `@deepseek-ai/dsh-cli-demo` 应用上运行一个完整模型/工具轮次,持久化新会话,打印 `text`、`json` 或 `stream-json`,然后退出。 运行:`pnpm run demo:headless "task"`(需要 `DEEPSEEK_API_KEY`)。输出契约、安全边界和快照套件详见 [headless-agent/README.md](headless-agent/README.md)。 @@ -22,22 +18,18 @@ ## jsonrpc-agent -通过 Python SDK 驱动的无人值守编码 agent:JSON-RPC stdio、仅前台 `bash`、`read`/`write`/`edit`、一个前台 `subagent`、`todo_write`、JSONL 持久化和压缩。它不包含终端 UI、stdout 日志、批准、skill(技能)和后台任务控制。详见 [jsonrpc-agent/README.md](jsonrpc-agent/README.md)。 +通过 Python SDK 驱动的无人值守编码 agent:JSON-RPC stdio、仅前台 `bash`、`read`/`write`/`edit`、一个前台 `subagent`、`todo_write`、JSONL 持久化和压缩。它不包含终端 UI、stdout 日志、批准、skill 和后台任务控制。详见 [jsonrpc-agent/README.md](jsonrpc-agent/README.md)。 ## web-cordis -**自指** 演示:编码主干加 [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis),其三个工具(`cordis_inspect`/`cordis_mount`/`cordis_unmount`)使 agent 可以检查当前 DSH 进程、挂载模型编写的临时插件(事件监听器、一个全新工具,或一个供另一个临时插件注入的服务),并再次卸载它们。这些插件只存在于内存中,共享一个内部 `cordis-dynamic` fiber 子树;`ctx.fs`/`ctx.web` 仅作为它们可用的能力提供方。 +**自指** 演示:编码主干加 [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis),其三个工具(`cordis_inspect`/`cordis_mount`/`cordis_unmount`)使 agent 可以检查当前 DSH 进程、挂载模型编写的临时 Plugin(事件监听器、一个全新工具,或一个供另一临时 Plugin 注入的服务),并再次卸载它们。这些 Plugin 只存在于内存中,共享一个内部 `cordis-dynamic` fiber 子树;`ctx.fs`/`ctx.web` 仅作为它们可用的能力提供方。 -<<<<<<< HEAD -使用 `pnpm run demo:cordis` 运行 TUI,使用 `pnpm run demo:cordis web` 在 `http://127.0.0.1:3081` 启动浏览器 UI,或使用 `pnpm run demo:cordis acp` 启动 ACP 服务器(三者均需 `DEEPSEEK_API_KEY`)。分阶段演示脚本详见 [cordis-agent/README.md](cordis-agent/README.md),设计与沙箱注意事项详见[工具集 Agent Note(agent 决策记录)](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 -======= 使用 `pnpm run demo:cordis` 在 `http://127.0.0.1:3081` 启动浏览器 UI,或使用 `pnpm run demo:cordis acp` 启动 ACP 服务器(两者均需 `DEEPSEEK_API_KEY`)。设计与沙箱注意事项详见[工具集 Agent Note](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) ## acp-agent -一个通过 JSON-RPC stdio 公开、作为 **Agent Client Protocol (ACP)** 自动化服务器运行的 agent,由 [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 提供。程序化客户端可以创建新会话、发送文本提示词、消费已提交的 assistant 文本、回答一次性权限请求并取消工作。它拥有 ACP 无密钥快照套件。 +作为 **Agent Client Protocol (ACP)** 自动化服务器通过 JSON-RPC stdio 公开的 agent,由 [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 提供。程序化客户端可以创建新会话、发送文本提示词、消费已提交的 assistant 文本、回答一次性权限请求并取消工作。它拥有 ACP 无密钥快照套件。 运行:`pnpm run demo:acp`(需要 `DEEPSEEK_API_KEY`);`pnpm run demo:code-mode acp` 通过 `code-mode.cordis.yml` 覆盖以 Code Mode 启动同一服务器。协议与快照测试契约详见 [acp-agent/README.md](acp-agent/README.md)。 -默认 `cordis.yml` 组合 [`@deepseek-ai/dsh-sandbox-local`](../packages/sandbox/sandbox-local)、[`@deepseek-ai/dsh-bash-sandbox`](../packages/bash/bash-sandbox) 和 [`@deepseek-ai/dsh-user-approval`](../packages/ui/user-approval)。`workspace-write` 将 bash 和文件系统变更限制在每个会话 workspace 中;请求更广泛沙箱权限的重试会通过 ACP 触发一次性的机器权限请求。 +默认 `cordis.yml` 组合 [`@deepseek-ai/dsh-sandbox-local`](../packages/sandbox/sandbox-local)、[`@deepseek-ai/dsh-bash-sandbox`](../packages/bash/bash-sandbox) 和 [`@deepseek-ai/dsh-user-approval`](../packages/ui/user-approval)。`workspace-write` 将 bash 和文件系统变更限制在每个会话 workspace 中;范围更广的重试会通过 ACP 成为一次性机器权限请求。 diff --git a/examples/code-mode/README.i18n.yaml b/examples/code-mode/README.i18n.yaml index 2b1171f393..03d2c40a85 100644 --- a/examples/code-mode/README.i18n.yaml +++ b/examples/code-mode/README.i18n.yaml @@ -1,12 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -<<<<<<< HEAD:examples/tui-agent/README.i18n.yaml -# pnpm run verify-translation-pairing --write examples/tui-agent/README.md -README.md: ea8695d37ea247a38644392a4572c1ea9855fd44 -README.zh.md: c6acd39d8713816d870c00fa8597754d0d09880a -======= # pnpm run verify-translation-pairing --write examples/code-mode/README.md README.md: 1557483ee98fab60aa63f8c5ec40f7e592c275ba README.zh.md: c20a9a7dde80eb0e03f17b62b5dc21189ef65f06 ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays):examples/code-mode/README.i18n.yaml diff --git a/examples/cordis-agent/README.i18n.yaml b/examples/cordis-agent/README.i18n.yaml deleted file mode 100644 index 4cbfbb67cb..0000000000 --- a/examples/cordis-agent/README.i18n.yaml +++ /dev/null @@ -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 examples/cordis-agent/README.md -README.md: 55970e932bc16d8361932daa9ea55af83ef73d33 -README.zh.md: a8ec332d8b3673d6656663eb3bfd7d37a4e328f6 diff --git a/examples/cordis-agent/README.zh.md b/examples/cordis-agent/README.zh.md deleted file mode 100644 index a8ec332d8b..0000000000 --- a/examples/cordis-agent/README.zh.md +++ /dev/null @@ -1,37 +0,0 @@ -# cordis-agent - -[English](README.md) | 中文 - -自指 harness 演示:在全屏 TUI 上运行 DeepSeek V4 编码主干,并加载 [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md)。后者让模型检查当前 DSH 进程、挂载仅存于内存的临时插件,并卸载它们。临时插件可跨轮次保持活跃,但会在卸载、工具集卸载或 DSH 重启后消失;它们不创建文件或配置,也可能影响同一进程中的其他会话。`ctx.fs` 和 `ctx.web` 仅以能力提供方形式加载,供这些插件使用。设计详见[工具集 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 - -## 运行 - -```sh -# repo root .env (gitignored) or exported env: -# DEEPSEEK_API_KEY=sk-… -# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:cordis # TUI (default) -pnpm run demo:cordis web # browser UI at http://127.0.0.1:3081 -pnpm run demo:cordis acp # ACP server -``` - -预期演示分阶段进行:先验证监听器链路,再让 agent(智能体)扩展自身: - -``` -> Mount a temporary Plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash. - [tool call] cordis_mount({"code": "return { name: 'status-logger', apply(ctx) { ctx.on('agent/status', (agent, status) => console.log('status →', status)) } }"}) - [tool result] Temporary Plugin dyn-1 is running (plugin "status-logger"; available until unmounted or DSH restarts). - [tool call] bash({"command": "echo hi"}) -[cordis:dyn-1] status → … ← the temporary listener firing, live -> Now give yourself a reverse_text tool and use it on "harness". - [tool call] cordis_mount({"code": "return { name: 'reverse-text', inject: ['tools'], apply(ctx) { ctx.tools.register(harness.defineTool({ name: 'reverse_text', … })) } }"}) - [tool call] reverse_text({"text": "harness"}) ← a tool the agent built for itself, one step earlier -> Unmount both temporary Plugins. - [tool call] cordis_unmount({"id": "dyn-1"}) -``` - -请求 `cordis_inspect` 并使用 `what: "api"` 或 `what: "events"`,即可查看编写插件代码所用的生成服务/事件资料。还可挂载两个协作临时插件(一个中调用 `ctx.provide`,另一个中使用 `inject`),观察 Cordis 如何暂停并恢复消费方。 - -## 端到端测试 - -`tests/keyless-smoke.e2e.ts` 使用虚拟密钥通过 Loader 启动真实 `cordis.yml`,并断言横幅、包名解析,以及收到 EOF 后正常退出。`tests/cordis-tools.e2e.ts` 是带密钥的冒烟测试:真实模型挂载一个临时状态监听器,测试验证其带标记的控制台输出行;然后创建并使用 `reverse_text` 工具,再通过 provide/inject 组合两个临时插件。[`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) 包含相关单元测试,并受逐文件 100% 覆盖率门禁约束。 diff --git a/examples/tui-agent/README.zh.md b/examples/tui-agent/README.zh.md deleted file mode 100644 index c6acd39d87..0000000000 --- a/examples/tui-agent/README.zh.md +++ /dev/null @@ -1,80 +0,0 @@ -# tui-agent - -[English](README.md) | 中文 - -全屏交互式编码 agent(智能体):DeepSeek V4、本地 bash 与文件系统工具、压缩(compaction)、subagent、工作流与全新 agent Ralph 迭代、plan mode(`/plan` 进入,`exit_plan_mode` 评审退出)、超时/溢出策略,以及通过 [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo) 提供的 JSONL 持久化;该应用从 `cordis.yml` 加载。同级 [`headless-agent`](../headless-agent/README.md) 以适合管道调用的单次任务形式运行同一能力类,[`acp-agent`](../acp-agent/README.md) 则通过 JSON-RPC 提供该能力。 - -## 运行 - -```sh -# repo root .env (gitignored) or exported env: -# DEEPSEEK_API_KEY=sk-… -# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:tui -``` - -演示脚本和可安装的 `dsh` CLI(命令行界面,见 [`apps/cli`](../../apps/cli/README.md))都会以此示例的 `cordis.yml` 作为已交付的默认配置启动;`dsh` 还会应用 `~/.dsh` 中的个人覆盖,并将调用目录作为工作区。 - -输入一项编码任务。agent 使用 `read`/`write`/`edit` 文件系统工具处理常规文件操作,使用 `bash`(加上面向后台任务的通用 `task_output`/`task_list`/`task_kill`)执行 shell 命令、搜索和测试。每次 bash 调用都在新的 `bash -c` 中运行(系统提示词要求模型传递 `workdir`,而不是使用 `cd`)。文件系统工具和 bash 都会相对于会话工作区解析相对路径。agent 还可以通过 `subagent`/`subagent_fork` 委托。 - -`todo_write` 任务跟踪器是选用的,不在已交付配置中:请将 `@deepseek-ai/dsh-tool-todo` 添加到 `cordis.yml`(或在 `~/.dsh` 下使用个人配置覆盖)以公开该工具。加载后,模型会把整表计划记录到会话日志,TUI 则渲染它。 - -TUI 渲染 Markdown 历史、推理(reasoning)、工具自有的终端/diff/通用卡片、token 总量,以及加载 `todo_write` 时的最新计划。较长的工具正文保留首尾预览;Ctrl+O 展开或折叠所有卡片。Enter 用于提交,或在 agent 运行时进行 steering(中途引导);Ctrl+R 切换推理,Escape 取消,`/help` 列出命令。`/plan` 为下一步骤选择 plan mode;`/plan ` 还会将消息提交到该步骤,`/plan off` 则在没有模型输入的情况下选择默认 mode。`/status` 会展开当前会话的标识、活动计数、精确 token/缓存 bucket、上下文用量和时间戳,而不中断正在运行的轮次。`/model` 打开当前提供方目录的键盘选择器;使用 Up/Down 聚焦模型,使用 Shift+Tab 循环切换为该模型公布的推理强度,再用 Enter 选择;也可以使用 `/model ` 和 `/model /` 直接选择。`ask_user_question` 会打开一个位于左下方的宽键盘面板,包含批次进度和编号选项。 - -### 恢复早先的会话 - -每次运行默认都会启动新会话(其事件日志落在 `./.sessions/` 下)。如需 **继续** 先前对话,请将其 id 传给已安装的 `dsh` CLI:此时 `main` agent 会重新水化持久日志,而不会从头开始,因此模型会将早先轮次视为历史: - -```sh -dsh --resume -``` - -`/resume` 打开可搜索键盘选择器,显示标题、活动、上一轮结果、模型路由、持久化目标阶段和实时/已持久化状态。已安装的 `dsh` 宿主会等待刷写完成,对当前应用执行 dispose(资源释放),然后以 `dsh --resume ` 替换进程。TUI 仍会在退出时打印该命令,并在自定义宿主无法移交时显示它。`dsh --resume ` 在启动上下文中提供 id,`cordis.yml` 会读取它(`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`);没有标志时,agent 会开始新会话。缺失或无法读取的 id 不会启动 agent,而会发出 `agent-loop/config-start-failed`:TUI 打印失败并以非零状态退出。选择器没有跨进程会话锁,因此拥有并发宿主的部署必须自行协调会话所有权。 - -## Code Mode - -[`code-mode.cordis.yml`](code-mode.cordis.yml) 在同一树上覆盖 worker 线程运行时和 `tools: { mode: code }`。模型会收到一个 `run_code` 传输工具,加上一份为可见工具生成的 TypeScript SDK;只有程序输出会返回模型上下文。使用 `mode: both` 可在 `run_code` 旁同时公开原生调用。执行契约详见 [Code Mode Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。 - -```sh -pnpm run demo:code-mode # this overlay under the TUI (default UI) -pnpm run demo:code-mode acp # the acp-agent example's same-shaped overlay -``` - -尝试一项横跨多个工具调用的任务,例如: - -> 统计 docs/ 下每个 `*.md` 文件的行数,并将最大的三个写入 summary.txt。 - -然后观察 transcript(文本记录):一次 `run_code` 调用、一个循环调用工具的程序,以及模型筛选后的结果,而不是五次原始工具输出往返。 - -## 每个叶节点配置项所演示的内容 - -此示例是轻量叶节点 `cordis.yml`:它选择可替换后端、加载一个应用包(package),并添加有意放在共享主干外的产品工具。主干(会话、系统提示词、工具、agent、不变式、`agent-loop`)和入口集群(JSONL 持久化、pi-tui 通道、预创建的 `main` agent)位于 [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo) 应用及其加载的 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) 组合包中;叶节点负责接线后端与面向模型的可选工具: - -| 配置项 | 演示内容 | -|---|---| -| `hmr` (`@cordisjs/plugin-hmr`) | 开发/演示的编辑-重载循环:它是 **叶节点** 配置项(不内置到应用),因为它依赖 Loader 的内部模块访问 | -| `llm-deepseek` | 默认原生适配器 | -| `bash` (`dsh-bash-local`) | 执行器实现:bash seam 中可替换的实现侧。面向模型的 `bash` schema(`tool-bash`)和通用 `task_*` 控制(`tool-tasks`)由 `dsh-agent-spine-demo` 提供,因此叶节点只选择执行器 | -| `tui-agent` (`@deepseek-ai/dsh-tui-demo`) | 应用组合包:agent-spine 演示 + JSONL 持久化 + pi-tui 通道 + 预创建的 `main` agent | -| `subagent`, `subagent-spawn`, `subagent-fork` | subagent 提供方注册表加两个进程内后端:新子 agent,以及用父 agent 已完成轮次前缀播种的子 agent | -| `tool-subagent`, `tool-subagent-fork` | 两次面向模型的 `dsh-tool-subagent` 加载,每次绑定不同提供方,并以不同工具名(`subagent`、`subagent_fork`)公开 | -| `workflow-workerthread`, `tool-workflow` | worker 线程工作流引擎及其面向模型的 `workflow` 工具,子调用通过 spawn 后端路由 | -| `plan-mode` | 插件拥有的 `/plan [message]` 进入命令和 `/plan off` 退出命令、plan-mode 提示词策略、工具限制,以及经评审的 `exit_plan_mode` 转换 | -| `fs-local`, `fs-policy`, `tool-fs` | 文件系统栈:本地 `ctx.fs` 提供方、先读后写/编辑策略门禁(位于 `fs/*` 事件门禁),以及面向模型的 `read`/`write`/`edit` 工具。相对路径相对于会话工作区解析 | - -## 端到端测试(`pnpm run test:e2e`) - -与 UI 无关的带密钥套件通过 `tests/harness.ts` 以程序方式组装完整栈(无 PTY、无 Loader): - -- `tests/full-loop.e2e.ts`:canary 测试:真实模型通过真实 bash 工具运行 `echo e2e-ok`;断言 `tool/call`/`tool/result` 会话事件和最终答案。 -- `tests/coding-task.e2e.ts`:类 swebench 冒烟测试:临时目录包含 `add.js`(其中 `a - b` 写在本应是 `a + b` 的位置)和失败的 `add.test.js`;agent 必须修复错误并验证。测试会自行重新运行 `node add.test.js` 并检查文件,不信任 agent 的说法。 -- `tests/resume.e2e.ts`:跨进程持久连续性:第一次运行告诉真实模型一个密码并将轮次持久化到临时 JSONL 根目录,然后 dispose 整个上下文;第二次运行在同一根目录上创建新上下文,恢复会话 id 并要求模型回忆密码。只有重新水化的日志能够提供该回忆。 -- `tests/compaction.e2e.ts`:压缩冒烟测试:一项真实多步 bash 任务在故意设得很小的上下文窗口中运行,使自动压缩监听器在会话中途触发。测试验证外部状态:真实日志中出现 `compact/start…end` 对,模型可见内容缩减(一个替换节点遮蔽了较旧节点),且 agent 在压缩后仍给出正确最终答案。 -- `tests/todo-write.e2e.ts`:加载选用的 `todo_write` 工具,由真实模型驱动,测试验证产生的 `todo/write` 会话事件。 -- `tests/code-mode.e2e.ts`:带密钥 Code Mode 证明:使用真实模型和双工具任务,断言协议层工具列表精确为 `[run_code]`,`tool/code-dispatch` 事件位于父调用下,且筛选后的答案已返回。 - -这些测试在没有 `DEEPSEEK_API_KEY` 时自行跳过。无密钥 `tests/tui-keyless-smoke.e2e.ts` 通过 PTY 启动真实 Loader 树(唯一获准的 PTY 界面):基础启动 + `/plan` + `/exit`,一次带问题对话框和工具往返的脚本 LLM(大语言模型)对话,Code Mode 覆盖配置的欢迎行,以及恢复失败退出路径。 - -## 快照测试 - -`tests/snapshots//session.jsonl` 提供已录制的用户提示词和模型分片;同级子日志驱动 subagent 和工作流。无密钥套件通过真实循环和工具实现执行这些脚本,然后比较可读的预期终端单元格/样式输出。对于仅涉及展示的变更,使用 `pnpm run test:snapshot:refresh`;已录制的模型流程改变时,使用 DeepSeek 密钥运行 `pnpm run test:snapshot:record`。已实现的 [TUI 快照 Agent Note](../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) 规定了场景矩阵,以及已录制旅程、包级瞬态快照与 PTY 覆盖之间的分工。 diff --git a/packages/examples/README.i18n.yaml b/packages/examples/README.i18n.yaml index ddf815f1b1..0b0ee3db7c 100644 --- a/packages/examples/README.i18n.yaml +++ b/packages/examples/README.i18n.yaml @@ -2,10 +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 -<<<<<<< HEAD -README.md: c229cef22087ac290bf862d6b3e31fdb533858c4 -README.zh.md: 208b9a138506785ea1dd2d83ddfbba29e4b7968e -======= README.md: d3ad432e71036db0d21f059e52f5d32e58010c42 README.zh.md: 0218e60df2511974b8eb222e23331c5a70c9df60 ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/packages/examples/README.zh.md b/packages/examples/README.zh.md index 7ec12eb1c6..0218e60df2 100644 --- a/packages/examples/README.zh.md +++ b/packages/examples/README.zh.md @@ -2,25 +2,16 @@ [English](README.md) | 中文 -预先组合的插件 bundle(组合包),供轻量叶节点 `cordis.yml` 加载,无需手工组装主干和前端入口。这些是 **演示/参考** 包(package);npm 名称的 `-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 名称 | 角色 | |---|---|---| -<<<<<<< HEAD -| `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 主干,打包为一个组合包插件,带后备会话标题和选用的持久目标栈 | ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) | `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | 无头单次应用:主干 + JSONL 持久化 + 预创建的 `main` agent,提供文本和 DSH 原生 JSON 输出 | -| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP(Agent 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 客户端使用 | -<<<<<<< HEAD -`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 会启动它。 ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) 这些 **不是** 产品 API。它们打包的主干组件位于 [`core/`](../core/README.md),人类/SDK 通道和启动粘合代码位于 [`ui/`](../ui/README.md),自动化传输位于 [`acp/`](../acp/README.md),可替换后端位于各自能力组;演示组合包只选定其中一种具体组合。可以自由替换或 fork。 @@ -28,4 +19,4 @@ ## jsonrpc bin/exe 名称是历史遗留 -`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 统一该启动流程时才会协调它们,而不会在此次移动中处理。 diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index 48d17b5391..59caccb8a1 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/README.i18n.yaml @@ -2,10 +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 -<<<<<<< HEAD -README.md: 359e7153be2f480ba3fea4b06782acdc9f89ebb9 -README.zh.md: acd8c06940b03e90e368314cd725846a2b92b656 -======= README.md: 6fde5c64052e369541787f90c12c33a7b0b8f9eb README.zh.md: d5b0e2b50707750132bdeae9d7e56d513b0f40a7 ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index 96a4354b03..d5b0e2b507 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -将 **默认的不含执行器、不含 UI 的 agent(智能体)主干** 作为一个 Cordis 组合包插件。它加载每个 harness agent 所需的固定服务集合,包括本地 skill(技能)提供方,并将循环的 `agents` 列表作为自身配置转发。因此,应用包(package)只需添加前端入口和可替换后端,就能组合出可工作的 agent。 +将 **默认的不含执行器、不含 UI 的 agent 主干** 作为一个 Cordis 组合包插件。它加载每个 harness agent 所需的固定服务集合,包括本地 skill 提供方,并将循环的 `agents` 列表作为自身配置转发。因此,应用包只需添加前端入口和可替换后端,就能组合出可工作的 agent。 阅读此包可了解完整插件树及其组合顺序。 @@ -43,15 +43,11 @@ 主干包含每个前端入口都共有的全部组件。可替换组件和与前端入口耦合的组件留在外部,由加载组合包的一方选择: -- **LLM(大语言模型)适配器**:组合包交付抽象 `llm` 服务;叶节点在 `ctx.llm` 上注册具体适配器(`llm-deepseek`、`llm-pi-ai`、`llm-replay`)。 -- **基于模型的会话标题提供方**:组合包挂载带可覆盖示例限制的后备服务(5 个词、40 个后备字节、80 个可接受标题字节);叶节点可以恰好选用一个首消息或全消息 LLM 提供方。 +- **LLM 适配器**:组合包交付抽象 `llm` 服务;叶节点在 `ctx.llm` 上注册具体适配器(`llm-deepseek`、`llm-pi-ai`、`llm-replay`)。 +- **模型支持的会话标题提供方**:组合包挂载带可覆盖示例限制的后备服务(5 个词、40 个后备字节、80 个可接受标题字节);叶节点可以恰好选用一个首消息或全消息 LLM 提供方。 - **bash 执行器**:组合包交付 `tool-bash`(消费方 schema);叶节点提供 `ctx.bash`(`bash-local` 或沙箱化实现)。 - **非本地 skill 提供方**:组合包交付 skill 注册表、本地文件系统提供方和 `skill` 工具;部署可以把嵌入式目录或远程目录等其他提供方作为同级插件添加。 -<<<<<<< HEAD -- **前端入口与各应用基础设施**:终端 TUI 或 ACP(Agent Client Protocol)自动化传输,以及 `hmr`。应用包([`dsh-tui-demo`](../tui-demo/README.md)、[`dsh-acp-demo`](../acp-demo/README.md))拥有这些选择。`timer` 位于主干中,因为它是共有组件且不写 stdout;前端入口拥有 stdout,因此留在组合包外。 -======= - **前端入口与各应用基础设施**:终端 TUI 或 ACP 自动化传输,以及 `hmr`。应用包([`dsh-cli-demo`](../cli-demo/README.md)、[`dsh-acp-demo`](../acp-demo/README.md))拥有这些选择。`timer` 位于主干中,因为它是共有组件且不写 stdout;前端入口拥有 stdout,因此留在组合包外。 ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) 这把[接口/实现/消费方 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) 提升到组合层:组合包拥有共享主干,叶节点拥有后端,应用包拥有前端入口。 @@ -63,25 +59,25 @@ import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -组合包将每个字段转发给拥有它的子节点:`agents` 与 `maxParallelToolCalls` 交给 `agent-loop`(`agents` 默认为 `[]`,上限在该处默认),因此每个应用提供自己的预创建 agent;TUI 和无头应用预创建 `main`,ACP 应用则在 `session/new` 按需创建 agent;`persona` 与 `toolOrder` 交给 `dsh-system-prompt`;`tools` 交给工具注册表以配置呈现模式;`sessionTitle` 交给后备标题服务;`skills.registry`、`skills.local` 与 `skills.tool` 分别交给 skill 注册表、本地提供方和面向模型的消费方;必填的 `workspaceContext` 选择交给 `dsh-workspace-context`(`{ maxBytes }` 启用加载,`false` 禁用);`invariants` 交给不变式服务;`toolBash`/`toolTasks` 交给组合包拥有的两个面向模型工具插件。组合包始终挂载 `dsh-llm-retry`,而每个叶节点适配器拥有自己的嵌套 `retryPolicy`。省略 `sessionTitle` 时采用显式示例策略:5 个词、40 个后备字节、80 个可接受标题字节。`goals` 对象会选用持久化领域、模型工具和同会话 Goal Round 驱动器,并将 `goals.domain` 与 `goals.tool` 转发给各自拥有者;省略或设为 `false` 会让整个栈缺席,使无头调用方继续以单轮次结算。设置 `skills.enabled: false` 会同时省略本地提供方和面向模型的 skill 工具;设置 `toolTasks: false` 会保留供前台生产方使用的任务服务,但不公开 `task_output`/`task_list`/`task_kill`。它对 `dshHome` 只解析一次,解析通过 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 完成,并将所得绝对值转发给 tool-bash 的托管环境和已启用的本地 skill 发现。顶层 `dshHome` 缺席时采用 `skills.local.dshHome`;两者同时提供但解析后的路径不同会明确失败。`toolBash.enableRunInBackground` 只控制 bash 生产方;独立加载的生产方保留各自配置。工作区指令先于 skill 目录注册,因此其会话前缀消息先渲染。应用包使用 `pickSpineConfig()`,只复制这些由组合包拥有的字段。 +组合包将每个字段转发给拥有它的子节点:`agents` 与 `maxParallelToolCalls` 交给 `agent-loop`(`agents` 默认为 `[]`,上限在该处默认),因此每个应用提供自己的预创建 agent;TUI 和无头应用预创建 `main`,ACP 应用则在 `session/new` 按需创建 agent;`persona` 与 `toolOrder` 交给 `dsh-system-prompt`;`tools` 交给工具注册表以配置呈现 mode;`sessionTitle` 交给后备标题服务;`skills.registry`、`skills.local` 与 `skills.tool` 分别交给 skill 注册表、本地提供方和面向模型的消费方;必填的 `workspaceContext` 选择交给 `dsh-workspace-context`(`{ maxBytes }` 启用加载,`false` 禁用);`invariants` 交给不变式服务;`toolBash`/`toolTasks` 交给组合包拥有的两个面向模型工具插件。组合包始终挂载 `dsh-llm-retry`,而每个叶节点适配器拥有自己的嵌套 `retryPolicy`。省略 `sessionTitle` 时采用显式示例策略:5 个词、40 个后备字节、80 个可接受标题字节。`goals` 对象会选用持久领域、模型工具和同会话驱动器,并将 `goals.domain` 与 `goals.tool` 转发给各自拥有者;省略或设为 `false` 会让整个栈缺席,使无头调用方继续以一轮结算。设置 `skills.enabled: false` 会同时省略本地提供方和面向模型的 skill 工具;设置 `toolTasks: false` 会保留供前台生产方使用的任务服务,但不公开 `task_output`/`task_list`/`task_kill`。它对 `dshHome` 只解析一次,解析通过 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 完成,并将所得绝对值转发给 tool-bash 的托管环境和已启用的本地 skill 发现。顶层 `dshHome` 缺席时采用 `skills.local.dshHome`;两者同时提供但解析后的路径不同会明确失败。`toolBash.enableRunInBackground` 只控制 bash 生产方;独立加载的生产方保留各自配置。Workspace 指令先于 skill 目录注册,因此其会话前缀消息先渲染。应用包使用 `pickSpineConfig()`,只复制这些由组合包拥有的字段。 例如,`{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` 会让包拥有的配套插件保持挂载,但抑制被阻止的拥有者。Blocklist 匹配优先于 allowlist 匹配;正则表达式与生命周期规则见 [`dsh-invariants`](../../support/invariants/README.md)。 ## 为何使用代码组合包,而非共享 YAML include -YAML include 可以去重配置,却无法拥有 bin 或提供前端入口默认值。ACP 应用包默认接出协议纯净的 stdout,但叶节点仍可添加不安全的 logger。组合包子节点把服务注册到根 isolate-keyed store,因此叶节点的同级插件无需依赖加载顺序即可通过注入看到它们。 +YAML include 可以去重配置,却无法拥有 bin 或提供前端入口默认值。ACP 应用包默认接出协议纯净的 stdout,但叶节点仍可添加不安全的 logger。组合包子节点把服务注册到根 isolate-keyed store,因此注入这些服务的叶节点同级插件无需依赖加载顺序即可看到它们。 -重试策略可能在新的编号步骤中重复失败的请求。重试状态、提供方错误和失败的部分分片不进入模型历史;每次提供方尝试仍可能产生计费;always 模式没有尝试次数上限;前端入口从所有已记录步骤推导用量;重建的请求保留先前前缀,以便复用提供方缓存。 +重试策略可能在新的编号步骤中重复失败的请求。重试状态、提供方错误和失败的部分 chunk 不进入模型历史;每次提供方尝试仍可能产生计费;always mode 没有尝试次数上限;前端入口从所有已记录步骤推导用量;重建的请求保留先前前缀,以便复用提供方 cache。 ## 模型体验 -模型通过 `dsh-system-prompt`、`dsh-tool-skill`、`dsh-tool-bash`、`dsh-tools` 和 `dsh-llm-retry` 间接获得体验;还会通过 `dsh-tool-goal` 与 Goal Round 提示词获得体验,前提是启用 `goals`。组合包自身不添加面向模型的包装内容。 +模型通过 `dsh-system-prompt`、`dsh-tool-skill`、`dsh-tool-bash`、`dsh-tools` 和 `dsh-llm-retry` 间接获得体验;还会通过 `dsh-tool-goal` 与目标轮次提示词获得体验,前提是启用 `goals`。组合包自身不添加面向模型的包装内容。 #### KV Cache 影响 -不会直接失效;上述消费方负责请求前缀的任何变更。 +不会直接失效;具名消费方拥有请求前缀的任何变更。 -## 已知限制与暂缓事项 +## 已知限制与延后工作 -- **大部分主干集合固定在代码中**:`apply()` 始终挂载核心服务与 `tool-bash`;配置可以省略组合包内的目标、skill 与任务控制工具,但要替换循环或删除其他主干成员,就必须组合另一个组合包。 +- **大部分主干集合固定在代码中**:`apply()` 始终挂载核心服务与 `tool-bash`;配置可以省略组合包内的目标、skill 与任务控制工具,但要替换循环或删除其他主干成员,就必须组合另一个 bundle。 - **不变式 seam 与配套插件仍是固定成员**:`invariants.enabled: false` 或包筛选器会抑制检查,但不会移除服务或配套插件注册;Session 始终启用的校验与冻结是另一套机制。 diff --git a/packages/examples/tui-demo/README.i18n.yaml b/packages/examples/tui-demo/README.i18n.yaml deleted file mode 100644 index a9815be3a3..0000000000 --- a/packages/examples/tui-demo/README.i18n.yaml +++ /dev/null @@ -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 diff --git a/packages/examples/tui-demo/README.zh.md b/packages/examples/tui-demo/README.zh.md deleted file mode 100644 index 43681e9c77..0000000000 --- a/packages/examples/tui-demo/README.zh.md +++ /dev/null @@ -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-` 会话 id,并将它同时传给 TUI 与已配置的 agent。恢复运行会将两个组件都绑定到 `resumeSessionId`。TUI 先于主干挂载,因此它可以渲染匹配的配置启动失败,而不会留下空白终端。应用为 `/resume` 组合持久化和会话查询;嵌入宿主还可以提供 `tuiResumeHost`,用于原地移交进程。 - -## 入口 - -此包(package)不交付 bin。[`dsh`](../../../apps/cli/README.md) CLI 是终端入口:裸 `dsh` 启动已交付的 `examples/tui-agent/cordis.yml`(它挂载此组合包),而 `dsh --config ` 启动另一个挂载此组合包的叶节点配置。它加载 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`;权限提示需要批准服务和回答方。 diff --git a/packages/todo/README.i18n.yaml b/packages/todo/README.i18n.yaml index e726755dcf..12b2ce442d 100644 --- a/packages/todo/README.i18n.yaml +++ b/packages/todo/README.i18n.yaml @@ -2,10 +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 -<<<<<<< HEAD -README.md: 1e5ae9a1583b9e9d3913fcd1dca7ef11a5f391fe -README.zh.md: a77f788a41353ea547059864dc7cf73ac5025219 -======= README.md: 495851a13f70bc8e3cb2dc99da47ab305ca205fa README.zh.md: abdac592a312cb36d145e7d16e01821561090dcb ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/packages/todo/README.zh.md b/packages/todo/README.zh.md index d8d8ffc4db..abdac592a3 100644 --- a/packages/todo/README.zh.md +++ b/packages/todo/README.zh.md @@ -1,15 +1,11 @@ -# todo/:todo/规划能力家族 +# todo/:todo/规划能力系列 [English](README.md) | 中文 -面向模型的 todo 工具。它是单一 **产品**包(package):这里没有接口/实现 seam,因为该列表是由单一所有者管理的会话状态(每个 agent(智能体)会话拥有自己的列表),而非可替换能力。 +面向模型的 todo 工具。它是单一 **产品** 包(package):这里没有接口/实现 seam,因为该列表是由单一所有者管理的会话状态(每个 agent(智能体)会话拥有自己的列表),而非可替换能力。 | 包 | 职责 | ctx 键 | |---|---|---| | `tool-todo/` | 面向模型的 `todo_write` 工具;将完整列表写入会话日志(`todo/write`) | (注册到 `ctx.tools`) | -<<<<<<< HEAD -列表存在于事件溯源会话日志中(`SessionEventMap['todo/write']`,由 [`dsh-session`](../core/session) 拥有);本包是追加快照的轻量消费方。[TUI 应用](../examples/tui-demo)等 UI 以及宿主/客户端运行时会根据会话事件渲染该持久化列表。 -======= 列表存在于事件溯源会话日志中(`SessionEventMap['todo/write']`,由 [`dsh-session`](../core/session) 拥有);本包是追加快照的轻量消费方。[TUI 前端入口](../ui/tui)等 UI 以及宿主/客户端运行时会根据会话事件渲染该持久列表。 ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/packages/todo/tool-todo/README.i18n.yaml b/packages/todo/tool-todo/README.i18n.yaml index 8b1ac9bcc1..eb152a048e 100644 --- a/packages/todo/tool-todo/README.i18n.yaml +++ b/packages/todo/tool-todo/README.i18n.yaml @@ -2,10 +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 -<<<<<<< HEAD -README.md: b05ef43e7137dcf5678b1f1ad6d8c00b8a43baef -README.zh.md: 88a9f5d69ff52dcedbd8f27a4ace5fde3e0dc5a2 -======= README.md: f86a0d7331cc822a6568eeb14b89a64abaa23378 README.zh.md: 778c920198599546890c90d21badf2d7bb0f2551 ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/packages/todo/tool-todo/README.zh.md b/packages/todo/tool-todo/README.zh.md index 26328dde33..778c920198 100644 --- a/packages/todo/tool-todo/README.zh.md +++ b/packages/todo/tool-todo/README.zh.md @@ -6,66 +6,62 @@ ## 功能 -注册一个工具 `todo_write(todos: [{ content, status }])` 到 `ctx.tools`。模型每次调用都会发送完整列表,不存在部分更新或单项编辑。每次调用都会向调用 agent 的会话日志追加 `todo/write` 事件(完整列表快照),具体调用 `agent.session.append('todo/write', { todos })`;当前列表是最新的该类事件(回放时后写覆盖先写)。 +注册一个工具 `todo_write(todos: [{ content, status }])` 到 `ctx.tools`。模型每次调用都会发送完整列表,不存在部分更新或单项编辑。每次调用都会向调用 agent 的会话日志追加 `todo/write` 事件(完整列表快照),具体调用 `agent.session.append('todo/write', { todos })`;当前列表是最新的该类事件(回放时后写者胜)。 `status` 是 `pending`、`in_progress` 或 `completed` 之一。 ## 单一所有者 -该列表属于调用工具的唯一 agent 会话。不存在 subagent/共享/swarm scope:非 agent 调用方(没有 `exec.agent`)无处写入列表,因此会被拒绝。这是有意设置的 scope 限制,详见 Agent Note(agent 决策记录)。 +该列表属于调用工具的唯一 agent 会话。不存在 subagent/共享/swarm scope:非 agent 调用方(没有 `exec.agent`)无处写入列表,因此会被拒绝。这是有意设置的 scope 限制,详见 Agent Note。 ## 验证 -除 schema 的类型/必填/枚举检查外,`execute` 还会拒绝空或重复的 `content`、同时存在多个 `in_progress` 任务的情况(连贯计划最多只有一个活跃任务),以及 `content`/`status` 之外的任何条目键——扩展条目形状(id、嵌套)会明确报错而不是被静默压平,保证落日志的快照与模型自认为写入的内容一致。列表的顺序及及时更新由模型依照工具描述负责。 +除 schema 的类型/必填/枚举检查外,`execute` 还会拒绝空或重复的 `content`、同时存在多个 `in_progress` 任务的情况(连贯计划最多只有一个活跃任务),以及 `content`/`status` 之外的任何条目键——扩展条目形状(id、嵌套)会响亮失败而不是被静默压平,保证落日志的快照与模型自认为写入的内容一致。顺序与保持列表最新的纪律由模型根据工具描述负责。 ## 渲染 -<<<<<<< HEAD -规范结果为 `{ 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))。 ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) ## 会话投影 -当组合挂载了 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md))时,本包在一个注入的子插件中注册 `todos` 投影单元:`init` = `null`(尚无写入)、`apply` = 从每个 `todo/write` 取整表,并在每个 `turn/start` 清为 `null`(当前有效计划;`turn/end` 保留刚完成的清单;其余事件都返回同一个状态引用)、`view` = 恒等、`stateVersion` = 2。该键在本包中合并进 `SessionProjectionMap`(经接口包的 `/types` 出口);框架驱动该单元,载体通过历史尾页与 `session/projection` 推送帧提供该值。未挂载注册表的组合不受影响。生命周期理由见 [在下一轮次清空 todo 计划](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md)。 +当组合挂载了 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md))时,本包在一个注入式子插件下注册 `todos` 投影单元:`init` = `null`(尚无写入)、`apply` = 从每个 `todo/write` 取整表,并在每个 `turn/start` 清为 `null`(站立计划;`turn/end` 保留刚完成的清单;其余事件都返回同一个状态引用)、`view` = 恒等、`stateVersion` = 2。key 在本包合并进 `SessionProjectionMap`(经接口包的 `/types` 出口);框架驱动该单元,载体在历史尾页与 `session/projection` 推送帧上供给该值。未装注册表的组合不受影响。生命周期理由见 [下一轮清空 todo 计划条](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md)。 ## 导出形状 -函数/命名空间插件:导出 `name`/`inject`/`apply`,不提供默认导出。意外的 `export default` 会被 Loader 的 `unwrapExports` 折叠为默认导出,并导致 `inject` 丢失(参见 [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md))。 +函数/命名空间插件:导出 `name`/`inject`/`apply`,不提供默认导出。意外的 `export default` 会通过 Loader 的 `unwrapExports` 折叠模块并丢弃 `inject`(参见 [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md))。 ## 模型体验 ### 工具 schema -#### 模型看到的内容 +#### 模型所见内容 模型会看到生成的 [`todo_write` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-todo)。 #### Token 影响 -工具可见的每个请求都有固定的 schema token 开销。 +工具可见的每个请求都有固定 schema 成本。 #### KV Cache 影响 -只要定义和可见性不变,前缀就保持稳定。插件生命周期或 scope 限制可能会使从此 schema 起的缓存复用失效。 +只要定义和可见性不变,前缀就保持稳定。插件生命周期或 scope 限制可能会使此 schema 之后的复用失效。 ### 工具调用历史与结果 -#### 模型看到的内容 +#### 模型所见内容 -每个 assistant 工具调用都会在参数中保留整个替换列表。成功时原样返回 `Updated todo list: pending, in progress, completed.`。稳定失败文本为 ``Error: invalid todo: `content` must be a non-empty string``、`Error: invalid todos: duplicate content ""`、`Error: invalid todos: at most one task may be in_progress, got ` 和 `Error: todo_write requires an owning agent session`。完整 `todo/write` 会话事件是 UI 与回放状态,而非第二条模型消息。 +每个 assistant 工具调用都会在参数中保留整个替换列表。成功时精确返回 `Updated todo list: pending, in progress, completed.`。稳定失败文本为 ``Error: invalid todo: `content` must be a non-empty string``、`Error: invalid todos: duplicate content ""`、`Error: invalid todos: at most one task may be in_progress, got ` 和 `Error: todo_write requires an owning agent session`。完整 `todo/write` 会话事件是 UI 与回放状态,而非第二条模型消息。 #### Token 影响 -token 用量会随模型每次提交的完整列表增长,且这些调用参数会保留到压缩(compaction)。结果本身很小,且形状固定。 +Token 增长与模型每次提交的完整列表成比例,且这些调用参数会保留到压缩(compaction)。结果本身很小,且形状固定。 #### KV Cache 影响 仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 -## 已知限制与暂缓事项 +## 已知限制与延后工作 -- **仅单一所有者 scope**:列表属于唯一调用 agent 会话;subagent/共享/swarm scope 是有意设置的限制(参见「单一所有者」一节),非 agent 调用方会被拒绝。 -- **条目形状有意保持最小**:`content` 加三态 `status`;整表替换不需要稳定 id、优先级或 active-form 字段。 +- **仅单一所有者 scope**:列表属于唯一调用 agent 会话;subagent/共享/swarm scope 是有意裁减(参见「单一所有者」一节),非 agent 调用方会被拒绝。 +- **项目形状有意保持最小**:`content` 加三态 `status`;整表替换不需要稳定 id、优先级或 active-form 字段。 - **整表替换是唯一操作**:没有部分更新,也没有回读工具;模型每次调用都必须重新发送完整列表。 diff --git a/packages/ui/README.i18n.yaml b/packages/ui/README.i18n.yaml index 06460978a8..0c9f3488d6 100644 --- a/packages/ui/README.i18n.yaml +++ b/packages/ui/README.i18n.yaml @@ -2,10 +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/README.md -<<<<<<< HEAD -README.md: f08157d411a018141cdc21c487f81ae198f4de56 -README.zh.md: ed4fbf576224a61e680fca337ac5e60829f8a90e -======= README.md: 307ae14709b59e5c233aa930f20e30c87cdbab69 README.zh.md: f02f9d902d0b61ad0299fb3fb24b97cf99c56b21 ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/packages/ui/README.zh.md b/packages/ui/README.zh.md index 9ef39eee8d..f02f9d902d 100644 --- a/packages/ui/README.zh.md +++ b/packages/ui/README.zh.md @@ -2,25 +2,21 @@ [English](README.md) | 中文 -面向用户的交互通道和进程外 SDK 服务器。这些是**产品**包(package):由用户或 SDK 客户端直接操作的真实接口。 +面向用户的交互通道和进程外 SDK 服务器。这些是**产品** 包(package):由用户或 SDK 客户端直接操作的真实接口。 | 包 | 职责 | ctx 键 | |---|---|---| | `commands/` | 用户命令注册表:共享发现元数据、作用域遮蔽、取消以及 UI 直接分派 | `ctx.commands` | | `user-approval/` | 一次性用户审批机制、封闭的结果词汇、审计事件和逐会话审批策略 | `ctx.approval` | -| `permission/` | 面向用户的权限预设(`workspace-write`/`danger-full-access`):通过一项产品级选择组合沙箱模式与审批策略两个可调参数,并写入各自的会话事件 | `ctx.permission` | +| `permission/` | 面向用户的权限预设(`workspace-write`/`danger-full-access`):用一个产品级选择器组合沙箱模式与审批策略两个调节项,并写入各自的会话事件 | `ctx.permission` | | `user-interaction/` | UI 支持的确认工具所使用的抽象用户问答 seam | `ctx.userInteraction` | | `tool-ask-user/` | 模型侧 `ask_user_question` 工具,基于 `ctx.userInteraction` 实现 | (注册到 `ctx.tools`) | | `tui/` | 交互式 pi-tui 终端通道:渲染会话标题、事件和工具意图,响应 `ctx.userInteraction`,并托管由 effect 持有的插件浮层 | `ctx.tui`(驱动 `ctx.agents`) | | `jsonrpc/` | 面向进程外 SDK 客户端的 stdio JSON-RPC 服务器 | (驱动 `ctx.agents`) | -| `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、会明确报错的 Loader 保护机制、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) | +| `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、Loader 快速失败保护、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) | -UI 集成属于由客户端驱动的插件,而非对循环的修改:它使用现有的 `agent/*` 事件分类和 `dsh-agent` 工厂。[`tui`](tui/README.md) 是交互式终端入口,并提供终端本地的 `ctx.tui` 扩展服务;[`jsonrpc`](jsonrpc/README.md) 为进程外 SDK 客户端提供服务,非交互式的一次性任务则使用 `cli-demo`。[`commands`](commands/README.md) 是 TUI 使用的仅面向用户的发现与分派通道;命令输入和输出不会成为模型消息。 +UI 集成属于客户端驱动插件,而非对循环的修改:它使用现有的 `agent/*` 事件分类和 `dsh-agent` 工厂。[`tui`](tui/README.md) 是交互式终端入口,并提供终端本地的 `ctx.tui` 扩展服务;[`jsonrpc`](jsonrpc/README.md) 为进程外 SDK 客户端提供服务,而非交互式单次任务使用 `cli-demo`。[`commands`](commands/README.md) 是 TUI 使用的纯用户发现与分派通道;命令输入和输出不会成为模型消息。 -`user-approval`、`user-interaction` 和 `tool-ask-user` 位于此处,因为向用户提问是由 UI 支持的产品功能,并不属于无提供方的核心主干。`user-approval` 负责一次性的 `ctx.approval` 决策机制及其策略层级;应答逻辑仍由负责 agent(智能体)的通道或自动化传输层提供。`user-interaction` 保持提供方无关(`ctx.userInteraction`),`tool-ask-user` 是其模型侧消费方,而交互式 app 包提供具体的提供方。 +`user-approval`、`user-interaction` 和 `tool-ask-user` 位于此处,因为向用户提问是由 UI 支持的产品功能,并不属于提供方无关的核心主干。`user-approval` 持有一次性的 `ctx.approval` 决策机制及其策略层级;应答方仍归拥有 agent(智能体)的通道或自动化传输层所有。`user-interaction` 保持提供方无关(`ctx.userInteraction`),`tool-ask-user` 是其模型侧消费方,而交互式 app 包提供具体实现。 -<<<<<<< HEAD -基于 [`agent-spine-demo`](../examples/agent-spine-demo/README.md) 组合的可运行 app bundle 位于 [`examples/`](../examples/README.md)(`tui-demo`、`acp-demo`、`jsonrpc-demo`)。`acp-demo` 和 `jsonrpc-demo` 各自提供启动 bin;`tui-demo` bundle 则由产品 [`dsh`](../../apps/cli/README.md) CLI(命令行界面)启动。`ui/` 保留可复用的用户/SDK 通道插件和共享 `app-boot` 粘合层;仅供自动化使用的 ACP(Agent Client Protocol)传输层位于 [`acp/`](../acp/README.md)。每个入口都负责自己的 stdout 策略,叶子 `cordis.yml` 则提供后端与可选工具。 -======= 基于 [`agent-spine-demo`](../examples/agent-spine-demo/README.md) 组合的可运行 app bundle 位于 [`examples/`](../examples/README.md)(`cli-demo`、`acp-demo`、`jsonrpc-demo`)。`acp-demo` 和 `jsonrpc-demo` 持有启动 bin;产品 [`dsh`](../../apps/cli/README.md) CLI 不使用 bundle:它启动 `apps/cli` 中的平铺 config tree。`ui/` 保留可复用的用户/SDK 通道插件和共享 `app-boot` 粘合层;仅供自动化使用的 ACP 传输层位于 [`acp/`](../acp/README.md)。每个入口都持有自己的 stdout 策略,叶子 `cordis.yml` 则提供后端与可选工具。 ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) From b9fb7ef6d2f2311f7956603f344b60c485da05b0 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:21:58 +0800 Subject: [PATCH 26/77] test(agent-loop): cover launcher-owned identities --- .../tests/config-session-id.spec.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 761791d89f..fd41dc098a 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -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 { } 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, { From bddf6591115043cca21b4039789d78a02d361786 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:23:33 +0800 Subject: [PATCH 27/77] chore: trigger CI after coverage fix From b1697ffd29e25bac67f263949f9b190793595f3a Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:28:05 +0800 Subject: [PATCH 28/77] fix(web): assemble and package the shipped overlay --- apps/cli/package.json | 4 +++- apps/cli/web.cordis.yml | 4 ++-- apps/web/tests/scaffold.ts | 13 ++++++++----- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 87cbf29060..d7684e22fa 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -9,7 +9,9 @@ }, "files": [ "lib/bin.js", - "cordis.yml", + "base.cordis.yml", + "tui.cordis.yml", + "web.cordis.yml", "src" ], "license": "BSD-3-Clause", diff --git a/apps/cli/web.cordis.yml b/apps/cli/web.cordis.yml index 6a2d5df9e5..30ab13e8b2 100644 --- a/apps/cli/web.cordis.yml +++ b/apps/cli/web.cordis.yml @@ -70,10 +70,10 @@ name: '@deepseek-ai/dsh-fs-sandbox' - id: bash-local - remove: true + disabled: true - id: fs-local - remove: true + disabled: true # ── web-only host rows, the transport layer, and the browser roster ───────── diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 753cdc2953..b942040f0b 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -1,6 +1,6 @@ // Shared scaffold for the keyless browser e2e lane (Agent Note: // .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md). -// Boots the REAL web composition — the shipped apps/cli/cordis.yml through +// Boots the REAL web composition — the shipped base plus web overlay through // the vendored Loader (the same include boot AppCLIEntry drives), patched the // snapshot way — so a real chromium exercises the real HTTP/SSE wire, the // api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT: @@ -9,7 +9,7 @@ // from live session memory), refresh (keyless replay that rewrites goldens). // // Composition divergences from `dsh web`, all deliberate, all via include -// patches over the SAME tree (never a second yml): temp persistenceRoot; +// patches after the shipped surface overlay: temp persistenceRoot; // workspace-context disabled (recorded fixtures must not embed this repo's // AGENTS.md); session-title-llm disabled (its fire-and-forget title call // would race the loop for the session's replay cursor); webserver pinned to @@ -28,7 +28,7 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include, { type PatchOptions } from '@cordisjs/plugin-include' import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot' -import { assertEntriesLoaded } from '@deepseek-ai/dsh-app-boot' +import { assertEntriesLoaded, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay' import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import SessionStore, { @@ -60,8 +60,9 @@ export function webSnapshotMode(): WebSnapshotMode { throw new Error(`DSH_SNAPSHOT must be replay, record, or refresh; got ${JSON.stringify(value)}`) } -/** The shipped composition under test: apps/cli's config tree. */ -const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/cordis.yml') +/** The shipped composition under test: apps/cli's shared base and web overlay. */ +const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/base.cordis.yml') +const WEB_OVERLAY_PATH = join(REPO_ROOT, 'apps/cli/web.cordis.yml') // Replay publishes the provider catalog the gateway routes to (providers // mode, never catch-all: with llm-deepseek disabled no adapter exists, so a @@ -163,7 +164,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise Date: Wed, 29 Jul 2026 21:30:51 +0800 Subject: [PATCH 29/77] fix(docs): parse inserted composition rows --- apps/cli/composition.md | 48 ++++++++++++++++++++++++++++++++++++--- scripts/gen-doc-graphs.ts | 44 +++++++++++++++++------------------ 2 files changed, 67 insertions(+), 25 deletions(-) diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 9c0d5b5f06..0e2af8b863 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -96,8 +96,36 @@ flowchart LR cfg --> plugin_tui_fs_local plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_tui_llm_deepseek - plugin_tui_tools["tools
@deepseek-ai/dsh-tool-ask-user"] - cfg --> plugin_tui_tools + plugin_tui_hmr["hmr
@cordisjs/plugin-hmr"] + cfg --> plugin_tui_hmr + plugin_tui_invariants["invariants
@deepseek-ai/dsh-invariants"] + cfg --> plugin_tui_invariants + plugin_tui_session_invariant["session-invariant
@deepseek-ai/dsh-session/invariant"] + cfg --> plugin_tui_session_invariant + plugin_tui_agent_invariant["agent-invariant
@deepseek-ai/dsh-agent/invariant"] + cfg --> plugin_tui_agent_invariant + plugin_tui_scope_invariant["scope-invariant
@deepseek-ai/dsh-scope/invariant"] + cfg --> plugin_tui_scope_invariant + plugin_tui_agent_loop_invariant["agent-loop-invariant
@deepseek-ai/dsh-agent-loop/invariant"] + cfg --> plugin_tui_agent_loop_invariant + plugin_tui_session_checkpoint_policy["session-checkpoint-policy
@deepseek-ai/dsh-session-checkpoint-policy"] + cfg --> plugin_tui_session_checkpoint_policy + plugin_tui_session_query_sqlite["session-query-sqlite
@deepseek-ai/dsh-session-query-sqlite"] + cfg --> plugin_tui_session_query_sqlite + plugin_tui_session_reference["session-reference
@deepseek-ai/dsh-session-reference"] + cfg --> plugin_tui_session_reference + plugin_tui_tool_result_prune["tool-result-prune
@deepseek-ai/dsh-compact-tool-result-prune"] + cfg --> plugin_tui_tool_result_prune + plugin_tui_tool_goal["tool-goal
@deepseek-ai/dsh-tool-goal"] + cfg --> plugin_tui_tool_goal + plugin_tui_tool_ralph["tool-ralph
@deepseek-ai/dsh-tool-ralph"] + cfg --> plugin_tui_tool_ralph + plugin_tui_tui_prompt["tui-prompt
@deepseek-ai/dsh-tui/prompt"] + cfg --> plugin_tui_tui_prompt + plugin_tui_tui["tui
@deepseek-ai/dsh-tui"] + cfg --> plugin_tui_tui + plugin_tui_tool_ask_user["tool-ask-user
@deepseek-ai/dsh-tool-ask-user"] + cfg --> plugin_tui_tool_ask_user ``` | Plugin id | Package / module | @@ -146,7 +174,21 @@ flowchart LR | `agent-loop` | `@deepseek-ai/dsh-agent-loop` | | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -| `tools` | `@deepseek-ai/dsh-tool-ask-user` | +| `hmr` | `@cordisjs/plugin-hmr` | +| `invariants` | `@deepseek-ai/dsh-invariants` | +| `session-invariant` | `@deepseek-ai/dsh-session/invariant` | +| `agent-invariant` | `@deepseek-ai/dsh-agent/invariant` | +| `scope-invariant` | `@deepseek-ai/dsh-scope/invariant` | +| `agent-loop-invariant` | `@deepseek-ai/dsh-agent-loop/invariant` | +| `session-checkpoint-policy` | `@deepseek-ai/dsh-session-checkpoint-policy` | +| `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` | +| `session-reference` | `@deepseek-ai/dsh-session-reference` | +| `tool-result-prune` | `@deepseek-ai/dsh-compact-tool-result-prune` | +| `tool-goal` | `@deepseek-ai/dsh-tool-goal` | +| `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` | +| `tui-prompt` | `@deepseek-ai/dsh-tui/prompt` | +| `tui` | `@deepseek-ai/dsh-tui` | +| `tool-ask-user` | `@deepseek-ai/dsh-tool-ask-user` | Source configs: [`apps/cli/base.cordis.yml`](base.cordis.yml), [`apps/cli/tui.cordis.yml`](tui.cordis.yml). diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 54cdc11a50..642f97db66 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -8,6 +8,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, relative, resolve } from 'node:path' import ts from 'typescript' +import { DEFAULT_SCHEMA, load, Type } from 'js-yaml' import { collectEvents, collectServices } from './gen-cordis-catalog.ts' import { collectPackageGraph, @@ -566,29 +567,28 @@ function renderCapabilitySeams(pkgs: Pkg[]): string { return lines.join('\n') } -function parseExampleCordis(rel: string): ExamplePlugin[] { - const text = readFileSync(resolve(root, rel), 'utf8') - const plugins: ExamplePlugin[] = [] - let current: { id: string; name?: string } | null = null - const flush = (): void => { - if (current?.name) plugins.push({ id: current.id, name: current.name }) - } - for (const line of text.split('\n')) { - const id = /^-\s+id:\s+(.+?)\s*$/.exec(line) - if (id?.[1] !== undefined) { - flush() - current = { id: stripYamlScalar(id[1]) } - continue - } - const name = /^\s+name:\s+(.+?)\s*$/.exec(line) - if (name?.[1] !== undefined && current) current.name = stripYamlScalar(name[1]) - } - flush() - return plugins -} +const jsExpressionType = new Type('tag:yaml.org,2002:js', { + kind: 'scalar', + construct: (value: string | null): string => value ?? '', +}) +const cordisSchema = DEFAULT_SCHEMA.extend([jsExpressionType]) -function stripYamlScalar(value: string): string { - return value.trim().replace(/^['"]|['"]$/g, '') +function parseExampleCordis(rel: string): ExamplePlugin[] { + const document = load(readFileSync(resolve(root, rel), 'utf8'), { schema: cordisSchema }) + if (!Array.isArray(document)) throw new Error(`${rel}: expected a top-level config array`) + const plugins: ExamplePlugin[] = [] + const visit = (entries: unknown[]): void => { + for (const value of entries) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) continue + const entry = value as { id?: unknown; name?: unknown; insert?: unknown } + if (typeof entry.id === 'string' && typeof entry.name === 'string') { + plugins.push({ id: entry.id, name: entry.name }) + } + if (Array.isArray(entry.insert)) visit(entry.insert) + } + } + visit(document) + return plugins } const APP_EXAMPLES = [ From 0a2ed84665a7f00962cdf114de1023d634dbcf95 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:33:58 +0800 Subject: [PATCH 30/77] docs(tui): explain non-strict sibling service lookup --- packages/ui/tui/src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index a860400e2b..b1588f46f1 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -854,6 +854,9 @@ export function createTuiChat( overlayManager, // Optional and independently mounted: read at each use so config row order // cannot decide whether /resume works. + // The TUI and query provider are sibling Loader fibers. During a command + // callback Cordis may transiently mark the provider non-ACTIVE even though + // its init completed and its disposal is ordered after this consumer. sessionQuery: () => ctx.get('sessionQuery', false), ui, editor, From b7401d064acef5a3233a35f2be8756696a75da12 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:36:07 +0800 Subject: [PATCH 31/77] docs(ui): drop unrelated translation churn --- packages/ui/README.i18n.yaml | 4 ++-- packages/ui/README.md | 2 +- packages/ui/README.zh.md | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/ui/README.i18n.yaml b/packages/ui/README.i18n.yaml index 0c9f3488d6..7a6175f898 100644 --- a/packages/ui/README.i18n.yaml +++ b/packages/ui/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/README.md -README.md: 307ae14709b59e5c233aa930f20e30c87cdbab69 -README.zh.md: f02f9d902d0b61ad0299fb3fb24b97cf99c56b21 +README.md: f08157d411a018141cdc21c487f81ae198f4de56 +README.zh.md: ed4fbf576224a61e680fca337ac5e60829f8a90e diff --git a/packages/ui/README.md b/packages/ui/README.md index 307ae14709..f08157d411 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -19,4 +19,4 @@ A UI integration is a client-driver plugin, not a loop change: it consumes the e `user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with the channel or automation transport that owns the agent. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and interactive app packages provide concrete providers. -The runnable app bundles composed over [`agent-spine-demo`](../examples/agent-spine-demo/README.md) live in [`examples/`](../examples/README.md) (`cli-demo`, `acp-demo`, `jsonrpc-demo`). `acp-demo` and `jsonrpc-demo` own boot bins; The product [`dsh`](../../apps/cli/README.md) CLI uses no bundle: it boots the flat config trees in `apps/cli`. `ui/` keeps the reusable human/SDK channel plugins and shared `app-boot` glue; the automation-only ACP transport lives in [`acp/`](../acp/README.md). Each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools. +The runnable app bundles composed over [`agent-spine-demo`](../examples/agent-spine-demo/README.md) live in [`examples/`](../examples/README.md) (`tui-demo`, `acp-demo`, `jsonrpc-demo`). `acp-demo` and `jsonrpc-demo` own boot bins; the `tui-demo` bundle is booted by the product [`dsh`](../../apps/cli/README.md) CLI. `ui/` keeps the reusable human/SDK channel plugins and shared `app-boot` glue; the automation-only ACP transport lives in [`acp/`](../acp/README.md). Each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools. diff --git a/packages/ui/README.zh.md b/packages/ui/README.zh.md index f02f9d902d..ed4fbf5762 100644 --- a/packages/ui/README.zh.md +++ b/packages/ui/README.zh.md @@ -2,21 +2,21 @@ [English](README.md) | 中文 -面向用户的交互通道和进程外 SDK 服务器。这些是**产品** 包(package):由用户或 SDK 客户端直接操作的真实接口。 +面向用户的交互通道和进程外 SDK 服务器。这些是**产品**包(package):由用户或 SDK 客户端直接操作的真实接口。 | 包 | 职责 | ctx 键 | |---|---|---| | `commands/` | 用户命令注册表:共享发现元数据、作用域遮蔽、取消以及 UI 直接分派 | `ctx.commands` | | `user-approval/` | 一次性用户审批机制、封闭的结果词汇、审计事件和逐会话审批策略 | `ctx.approval` | -| `permission/` | 面向用户的权限预设(`workspace-write`/`danger-full-access`):用一个产品级选择器组合沙箱模式与审批策略两个调节项,并写入各自的会话事件 | `ctx.permission` | +| `permission/` | 面向用户的权限预设(`workspace-write`/`danger-full-access`):通过一项产品级选择组合沙箱模式与审批策略两个可调参数,并写入各自的会话事件 | `ctx.permission` | | `user-interaction/` | UI 支持的确认工具所使用的抽象用户问答 seam | `ctx.userInteraction` | | `tool-ask-user/` | 模型侧 `ask_user_question` 工具,基于 `ctx.userInteraction` 实现 | (注册到 `ctx.tools`) | | `tui/` | 交互式 pi-tui 终端通道:渲染会话标题、事件和工具意图,响应 `ctx.userInteraction`,并托管由 effect 持有的插件浮层 | `ctx.tui`(驱动 `ctx.agents`) | | `jsonrpc/` | 面向进程外 SDK 客户端的 stdio JSON-RPC 服务器 | (驱动 `ctx.agents`) | -| `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、Loader 快速失败保护、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) | +| `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、会明确报错的 Loader 保护机制、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) | -UI 集成属于客户端驱动插件,而非对循环的修改:它使用现有的 `agent/*` 事件分类和 `dsh-agent` 工厂。[`tui`](tui/README.md) 是交互式终端入口,并提供终端本地的 `ctx.tui` 扩展服务;[`jsonrpc`](jsonrpc/README.md) 为进程外 SDK 客户端提供服务,而非交互式单次任务使用 `cli-demo`。[`commands`](commands/README.md) 是 TUI 使用的纯用户发现与分派通道;命令输入和输出不会成为模型消息。 +UI 集成属于由客户端驱动的插件,而非对循环的修改:它使用现有的 `agent/*` 事件分类和 `dsh-agent` 工厂。[`tui`](tui/README.md) 是交互式终端入口,并提供终端本地的 `ctx.tui` 扩展服务;[`jsonrpc`](jsonrpc/README.md) 为进程外 SDK 客户端提供服务,非交互式的一次性任务则使用 `cli-demo`。[`commands`](commands/README.md) 是 TUI 使用的仅面向用户的发现与分派通道;命令输入和输出不会成为模型消息。 -`user-approval`、`user-interaction` 和 `tool-ask-user` 位于此处,因为向用户提问是由 UI 支持的产品功能,并不属于提供方无关的核心主干。`user-approval` 持有一次性的 `ctx.approval` 决策机制及其策略层级;应答方仍归拥有 agent(智能体)的通道或自动化传输层所有。`user-interaction` 保持提供方无关(`ctx.userInteraction`),`tool-ask-user` 是其模型侧消费方,而交互式 app 包提供具体实现。 +`user-approval`、`user-interaction` 和 `tool-ask-user` 位于此处,因为向用户提问是由 UI 支持的产品功能,并不属于无提供方的核心主干。`user-approval` 负责一次性的 `ctx.approval` 决策机制及其策略层级;应答逻辑仍由负责 agent(智能体)的通道或自动化传输层提供。`user-interaction` 保持提供方无关(`ctx.userInteraction`),`tool-ask-user` 是其模型侧消费方,而交互式 app 包提供具体的提供方。 -基于 [`agent-spine-demo`](../examples/agent-spine-demo/README.md) 组合的可运行 app bundle 位于 [`examples/`](../examples/README.md)(`cli-demo`、`acp-demo`、`jsonrpc-demo`)。`acp-demo` 和 `jsonrpc-demo` 持有启动 bin;产品 [`dsh`](../../apps/cli/README.md) CLI 不使用 bundle:它启动 `apps/cli` 中的平铺 config tree。`ui/` 保留可复用的用户/SDK 通道插件和共享 `app-boot` 粘合层;仅供自动化使用的 ACP 传输层位于 [`acp/`](../acp/README.md)。每个入口都持有自己的 stdout 策略,叶子 `cordis.yml` 则提供后端与可选工具。 +基于 [`agent-spine-demo`](../examples/agent-spine-demo/README.md) 组合的可运行 app bundle 位于 [`examples/`](../examples/README.md)(`tui-demo`、`acp-demo`、`jsonrpc-demo`)。`acp-demo` 和 `jsonrpc-demo` 各自提供启动 bin;`tui-demo` bundle 则由产品 [`dsh`](../../apps/cli/README.md) CLI(命令行界面)启动。`ui/` 保留可复用的用户/SDK 通道插件和共享 `app-boot` 粘合层;仅供自动化使用的 ACP(Agent Client Protocol)传输层位于 [`acp/`](../acp/README.md)。每个入口都负责自己的 stdout 策略,叶子 `cordis.yml` 则提供后端与可选工具。 From adacacaa4f8aa83431c855cf9275aa058e756ece Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:37:18 +0800 Subject: [PATCH 32/77] docs(client): point registration at web overlay --- packages/client/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 02e453415a..964f463841 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -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/` 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-`, 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/base.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/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 bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources. From 7135703a5ee6eb11d4cae49287d80c0252565ad1 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:38:03 +0800 Subject: [PATCH 33/77] fix(gates): scope CLI dependencies to shipped trees --- scripts/verify-cordis-config.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index 9a188a5c75..d0a0aa53a5 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -129,7 +129,12 @@ function validateExampleResolution(): string[] { function validateAppResolution(): string[] { const dependencies = readManifest('apps/cli/package.json').dependencies ?? {} - const references = pluginReferences.filter(reference => reference.file.startsWith('apps/cli/')) + const shipped = new Set([ + 'apps/cli/base.cordis.yml', + 'apps/cli/tui.cordis.yml', + 'apps/cli/web.cordis.yml', + ]) + const references = pluginReferences.filter(reference => shipped.has(reference.file)) return missingPluginDependencies(references, dependencies, 'apps/cli/package.json') } From d8e4fd3b7502c5ba3edcc88171656edcafc95dca Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:39:38 +0800 Subject: [PATCH 34/77] fix(cli): align meta invocation contract --- apps/cli/src/args.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index e470af1299..038ac96af4 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -29,15 +29,9 @@ interface HeadlessInvocation { prompt: string } -/** - * Interactive TUI over this harness checkout: `dsh meta`. Identical to - * {@link TuiInvocation} except the workspace is the launcher's own source tree - * rather than the invoking directory. No `--config`: booting a foreign tree - * against the harness workspace is the `--config` case, not this one. - */ +/** Interactive fresh TUI over this harness checkout; accepts no default-surface options. */ interface MetaInvocation { mode: 'meta' - resume?: string } /** @@ -168,13 +162,13 @@ Examples: // Commander parses the parent (default-surface) options on either side of a // subcommand into `program.opts()`. For a subcommand that shares none of them, - // a leaked `--config`/`-p`/`--resume` is a mistyped invocation that must fail + // a leaked config/prompt/resume option is a mistyped invocation that must fail // loud rather than silently run and drop the input. const rejectParentOptions = (command: string): void => { const parent = program.opts<{ config?: string; configReplace?: string; prompt?: string; resume?: string }>() if (parent.config !== undefined || parent.configReplace !== undefined || parent.prompt !== undefined || parent.resume !== undefined) { - program.error(`error: ${command} takes none of --config, -p/--prompt, or --resume`) + program.error(`error: ${command} takes none of --config, --config-replace, -p/--prompt, or --resume`) } } From ff126751d87c08331b3356ecdb063d5c1ebf2e65 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:41:51 +0800 Subject: [PATCH 35/77] refactor(cli): keep migrate out of scope --- apps/cli/README.i18n.yaml | 4 +-- apps/cli/README.md | 4 +-- apps/cli/README.zh.md | 4 +-- apps/cli/src/args.ts | 31 ++++++++------------ apps/cli/src/bin.ts | 1 - apps/cli/src/tui.ts | 4 +-- apps/cli/tests/args.spec.ts | 7 +---- skills/dsh-migrate/SKILL.md | 57 ------------------------------------- 8 files changed, 21 insertions(+), 91 deletions(-) delete mode 100644 skills/dsh-migrate/SKILL.md diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 6ef7959159..80c388280b 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 2589462dfb5fc600b0e480a5a41c32860bf6837d -README.zh.md: 3b5e5319b76d9b18b2719cc8e943faaf398c51af +README.md: 7cde0dd8c9c6cf794cf8d1676ed6938e204117cf +README.zh.md: 3b21d563cbd8458810cd05f1ef71bd88074f294f diff --git a/apps/cli/README.md b/apps/cli/README.md index 2589462dfb..7cde0dd8c9 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -3,7 +3,7 @@ English | [中文](README.zh.md) -Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`), whose `meta` subcommand is the same TUI over this checkout, whose `migrate`/`upgrade` subcommands are option-less guided-session entries, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `migrate`, `upgrade`, `web` — rejects a leaked `--config`/`-p`/`--resume` rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. +Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`), whose `meta` subcommand is the same TUI over this checkout, whose `upgrade` subcommands are option-less guided-session entries, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `migrate`, `upgrade`, `web` — rejects a leaked `--config`/`-p`/`--resume` rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. The TUI surface: @@ -15,7 +15,7 @@ The TUI surface: `dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after both `.env` layers are loaded, so environment precedence is unchanged while the session cwd and HMR watch root move together. Meta always starts a fresh session and accepts no default-surface options; use ordinary `dsh --resume ` to resume a persisted session. -`dsh migrate` and `dsh upgrade` are guided fresh-session entries over the default TUI surface: each mints a fresh session in the invoking directory and seeds its first turn with a bundled skill (`dsh-migrate` for migrating from another coding agent — opencode, pi, Claude Code, Codex; `dsh-upgrade` for upgrading this checkout), exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection. +`dsh upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection. The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`; `dsh web --config ` adds an overlay after the web surface defaults. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 3b5e5319b7..3b21d563cb 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -3,7 +3,7 @@ [English](README.md) | 中文 -Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`migrate`/`upgrade` 子命令是无选项的引导会话入口,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`migrate`、`upgrade`、`web`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 +Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`upgrade` 子命令是无选项的引导会话入口,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`migrate`、`upgrade`、`web`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 TUI 界面: @@ -15,7 +15,7 @@ TUI 界面: `dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在两层 `.env` 都加载之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume `。 -`dsh migrate` 与 `dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:各自在调用目录中创建一个全新会话,并以一个内置 skill 播种其首轮(`dsh-migrate` 用于从其他编码 agent 迁移——opencode、pi、Claude Code、Codex;`dsh-upgrade` 用于升级本 checkout),效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。 +`dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。 Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`;`dsh web --config ` 会在 Web 界面默认值之后追加一个覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 038ac96af4..ca898b08ea 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -35,13 +35,13 @@ interface MetaInvocation { } /** - * Guided fresh-session entries: `dsh migrate` seeds the first turn with the - * `dsh-migrate` skill, `dsh upgrade` with `dsh-upgrade`. Each always mints a + * Guided fresh-session entry: `dsh upgrade` seeds the first turn with the + * `dsh-upgrade` skill. It always mints a * fresh session in the invoking directory and takes no options — `--resume`, * `--config`, and `-p` are rejected as mistyped, so there is nothing to carry. */ interface SkillSessionInvocation { - mode: 'migrate' | 'upgrade' + mode: 'upgrade' } /** @@ -174,22 +174,15 @@ Examples: // Registration order is the rendered help order, so daily use comes first // and the harness-development surfaces (`web --dev`, `meta`) come last. - // `migrate` and `upgrade` are guided fresh-session entries: they take no - // options and always mint a fresh session, so nothing is left to carry. Each - // description names the outcome, not the skill the first turn invokes. - const guided = { - migrate: 'import settings from another coding agent (Claude Code, Codex, opencode)', - upgrade: 'update this dsh installation to the latest version', - } as const - for (const mode of ['migrate', 'upgrade'] as const) { - program - .command(mode) - .description(guided[mode]) - .action(() => { - rejectParentOptions(mode) - resolved = { mode } - }) - } + // `upgrade` is a guided fresh-session entry: it takes no options and always + // mints a fresh session, so nothing is left to carry. + program + .command('upgrade') + .description('update this dsh installation to the latest version') + .action(() => { + rejectParentOptions('upgrade') + resolved = { mode: 'upgrade' } + }) // Host and port name no default: the CLI passes neither through when the flag // is absent, so the shipped `cordis.yml` value stands and restating it here diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 619d0dcd58..6f7ae77c76 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -48,7 +48,6 @@ switch (invocation.mode) { await runMeta() break } - case 'migrate': case 'upgrade': { const { runSkillSession } = await import('./tui.ts') await runSkillSession(`dsh-${invocation.mode}`) diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 6964ac9753..e96f9b515e 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -10,7 +10,7 @@ * workspace, and an in-place resume enters the selected session's own directory. * `dsh meta` * ({@link runMeta}) is the one exception — it makes this harness checkout the - * workspace. `dsh migrate`/`dsh upgrade` ({@link runSkillSession}) are fresh + * workspace. `dsh upgrade` ({@link runSkillSession}) are fresh * sessions whose first turn auto-invokes a bundled skill. After boot, the * agent's system prompt is told the path to this harness checkout so it can * find its own source. @@ -91,7 +91,7 @@ export async function runMeta(): Promise { /** * Run the interactive TUI as a guided fresh session whose first turn invokes a - * bundled skill (`dsh migrate` → `dsh-migrate`, `dsh upgrade` → `dsh-upgrade`). + * bundled skill (`dsh upgrade` → `dsh-upgrade`). * Always mints a fresh session in the invoking directory; the skill is seeded * only on this first launch, so a later `--resume` of the session is an ordinary * TUI session with no re-injection. diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 7eea63b68d..ccbd5fa326 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -40,7 +40,6 @@ describe('parseDshArgs', () => { expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' }) // Guided fresh-session entries carry nothing: bare mode discriminant only. - expect(parse(['migrate'])).toEqual({ mode: 'migrate' }) expect(parse(['upgrade'])).toEqual({ mode: 'upgrade' }) // --trusted-host is variadic and repeatable; authorities pass through unvalidated. expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) @@ -73,12 +72,8 @@ describe('parseDshArgs', () => { expect(exitCode(['meta', '--config', 'c.yml'])).toBe(1) expect(exitCode(['meta', '--config-replace', 'tree.yml'])).toBe(1) expect(exitCode(['meta', '-p', 'task'])).toBe(1) - // `migrate`/`upgrade` take no options: any leaked default-surface flag is a + // `upgrade` take no options: any leaked default-surface flag is a // mistyped invocation, not a silently-dropped input. - expect(exitCode(['migrate', '--resume', 's'])).toBe(1) - expect(exitCode(['migrate', '--config', 'c.yml'])).toBe(1) - expect(exitCode(['migrate', '--config-replace', 'tree.yml'])).toBe(1) - expect(exitCode(['migrate', '-p', 'task'])).toBe(1) expect(exitCode(['upgrade', '--resume', 's'])).toBe(1) expect(exitCode(['upgrade', '--config', 'c.yml'])).toBe(1) expect(exitCode(['-p', 'task', 'upgrade'])).toBe(1) diff --git a/skills/dsh-migrate/SKILL.md b/skills/dsh-migrate/SKILL.md deleted file mode 100644 index fbea31f04f..0000000000 --- a/skills/dsh-migrate/SKILL.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -name: dsh-migrate -description: Migrate a user's setup from another coding agent (opencode, pi, Claude Code, Codex) to DSH — porting instruction files, custom commands and skills, hooks, MCP servers, and API/env configuration into their DSH equivalents. Use when the user asks to migrate, switch, or move from another coding agent to DSH. ---- - -# DSH Migrate - -Move a user's existing coding-agent setup onto DSH: instruction files, custom commands, skills, hooks, MCP servers, and API/environment configuration. Port only what has a real DSH equivalent; tell the user plainly when something has none. - -## First: identify the source - -Ask which agent the user is migrating from if they have not said: **opencode**, **pi**, **Claude Code**, or **Codex**. The mapping differs per source. Then locate that agent's config (ask the user, or inspect the obvious locations: `~/.claude/` and `.claude/` for Claude Code, `~/.codex/` and `.codex/` for Codex, the opencode/pi config dir the user names). Read what exists before proposing changes; never invent files the user does not have. - -## DSH targets - -Every migration lands in one of these DSH surfaces. Verify the exact path against the running install rather than assuming. - -- **Workspace instructions**: DSH reads `AGENTS.md` and `CLAUDE.md` (and `AGENTS.local.md` / `CLAUDE.local.md`) from the project, walking up to the project root, plus a user-global `~/.dsh/AGENTS.md`. `CLAUDE.md` is read as-is, so a Claude Code project needs no rename. -- **Personal overlay** (user-global, applies to every DSH session): the Harness home `~/.dsh/` holds `config.yaml` (a top-level YAML array of Loader patch entries that patch the booted plugin tree), `.env` (fills environment gaps only — ambient env and the invoking directory's `.env` win), `AGENTS.md`, and `skills/`. -- **Skills**: directory-bundle or flat-Markdown skills load from `.dsh/skills/` and `.agents/skills/` in the project, and `~/.dsh/skills/` and `~/.agents/skills/` for the user. Personal skills go in `~/.dsh/skills//SKILL.md`. Use the `skill-creator` skill to author them. -- **Hooks**: DSH runs a mapped subset of an existing Claude Code or Codex hook config through compatibility bridges — no rewrite needed for the supported subset. See the per-source sections. -- **MCP servers**: DSH has no native MCP client. Reach MCP servers through the `mcporter` skill / CLI, which can call servers already configured for other tools. -- **API / model config**: DSH uses `DEEPSEEK_API_KEY` (and optional `DEEPSEEK_BASE_URL`) from `.env` (root, invoking directory, or `~/.dsh/.env`). Model and provider are chosen in the booted `cordis.yml` / personal overlay, not per-provider config files. - -## Per-source mapping - -### Claude Code - -- `CLAUDE.md` → read as-is by DSH workspace instructions; keep it, or consolidate into `AGENTS.md`. User-global rules → `~/.dsh/AGENTS.md`. -- `.claude/hooks.json` (or a settings file's `hooks` key) → the `@deepseek-ai/dsh-hooks-claude` bridge runs the mapped command-hook subset on DSH's interception seams, with `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PROJECT_DIR}` substitution. Add it to the booted `cordis.yml` (or personal overlay) pointing `configPath` at the existing file. Anything outside the mapped subset should become a native DSH plugin, not a shimmed hook. -- Slash commands → DSH commands are plugin-provided; there is no drop-in import. Reimplement genuinely needed ones as skills (`~/.dsh/skills/`) or plugins. -- MCP servers in Claude config → use `mcporter` to reach them; DSH has no native MCP. -- `ANTHROPIC_API_KEY` etc. do not transfer; DSH is DeepSeek-backed via `DEEPSEEK_API_KEY`. - -### Codex - -- Codex `AGENTS.md` → DSH already reads `AGENTS.md`; keep it. User-global → `~/.dsh/AGENTS.md`. -- Codex hook config → the `@deepseek-ai/dsh-hooks-codex` bridge runs a deliberate subset (`PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, `Stop`; regex-only matchers; no plugin env injection; no pre-tool approval/rewrite). Add the bridge to the booted config with `configPath` at the existing Codex hooks file. State the unsupported points to the user rather than implying full parity. -- MCP servers → `mcporter`. -- API/env → `DEEPSEEK_API_KEY` in `.env`. - -### opencode / pi - -- These have no compatibility bridge. Port by concept, not by file: - - Agent/system instructions → `AGENTS.md` (project) and `~/.dsh/AGENTS.md` (user-global). - - Provider/model and any plugin-style tuning → the booted `cordis.yml` or `~/.dsh/config.yaml` overlay patches; API keys → `.env`. - - Reusable prompts/commands → skills under `~/.dsh/skills/`. - - MCP servers → `mcporter`. -- pi has no native MCP by design; the `mcporter` route is the same as for DSH. - -## Do the migration - -1. Confirm the source agent and read its actual config. -2. For each capability (instructions, hooks, commands/skills, MCP, API/env), map it to the DSH target above, or tell the user it has no equivalent. -3. Write the ported files (`AGENTS.md`, `~/.dsh/AGENTS.md`, `~/.dsh/config.yaml`, `~/.dsh/.env`, skills). For hook bridges, add the plugin entry to the booted config. -4. Verify: hooks need the bridge plugin present in the running tree; MCP needs `mcporter` reachable; API needs `DEEPSEEK_API_KEY` set. Test in a real DSH session, not just on paper. -5. Summarize what was ported, what was reimplemented, and what has no DSH equivalent. From bdc2b4667fce8a17ab70702a516c068360df2b9c Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:43:33 +0800 Subject: [PATCH 36/77] refactor(cli): keep hmr in shared base --- apps/cli/base.cordis.yml | 5 +++++ apps/cli/composition.md | 6 +++--- apps/cli/tui.cordis.yml | 7 ------- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/apps/cli/base.cordis.yml b/apps/cli/base.cordis.yml index cb1fd7925d..9f69fcc627 100644 --- a/apps/cli/base.cordis.yml +++ b/apps/cli/base.cordis.yml @@ -17,6 +17,11 @@ - id: timer name: '@cordisjs/plugin-timer' +- id: hmr + name: '@cordisjs/plugin-hmr' + config: + root: ['.'] + - id: llm name: '@deepseek-ai/dsh-llm' diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 0e2af8b863..4abe13c79b 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -10,6 +10,8 @@ flowchart LR cfg["apps/cli (dsh)
base.cordis.yml + tui.cordis.yml"] plugin_tui_timer["timer
@cordisjs/plugin-timer"] cfg --> plugin_tui_timer + plugin_tui_hmr["hmr
@cordisjs/plugin-hmr"] + cfg --> plugin_tui_hmr plugin_tui_llm["llm
@deepseek-ai/dsh-llm"] cfg --> plugin_tui_llm plugin_tui_session["session
@deepseek-ai/dsh-session"] @@ -96,8 +98,6 @@ flowchart LR cfg --> plugin_tui_fs_local plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_tui_llm_deepseek - plugin_tui_hmr["hmr
@cordisjs/plugin-hmr"] - cfg --> plugin_tui_hmr plugin_tui_invariants["invariants
@deepseek-ai/dsh-invariants"] cfg --> plugin_tui_invariants plugin_tui_session_invariant["session-invariant
@deepseek-ai/dsh-session/invariant"] @@ -131,6 +131,7 @@ flowchart LR | Plugin id | Package / module | | --- | --- | | `timer` | `@cordisjs/plugin-timer` | +| `hmr` | `@cordisjs/plugin-hmr` | | `llm` | `@deepseek-ai/dsh-llm` | | `session` | `@deepseek-ai/dsh-session` | | `session-title` | `@deepseek-ai/dsh-session-title` | @@ -174,7 +175,6 @@ flowchart LR | `agent-loop` | `@deepseek-ai/dsh-agent-loop` | | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -| `hmr` | `@cordisjs/plugin-hmr` | | `invariants` | `@deepseek-ai/dsh-invariants` | | `session-invariant` | `@deepseek-ai/dsh-session/invariant` | | `agent-invariant` | `@deepseek-ai/dsh-agent/invariant` | diff --git a/apps/cli/tui.cordis.yml b/apps/cli/tui.cordis.yml index f71be09e85..b1ba86e6c7 100644 --- a/apps/cli/tui.cordis.yml +++ b/apps/cli/tui.cordis.yml @@ -59,13 +59,6 @@ # ── TUI-only rows ─────────────────────────────────────────────────────────── - insert: - # Development-only hot reload; it depends on Loader internals, so it stays a - # surface row rather than joining the shared base. - - id: hmr - name: '@cordisjs/plugin-hmr' - config: - root: ['.'] - # Relational runtime checks over the authoritative event streams; each # companion registers the assertions its own package owns. - id: invariants From c20f94335ae7eaa6307a13ea73fb7cb372018113 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:47:20 +0800 Subject: [PATCH 37/77] docs(user): explain CLI overlay precedence --- docs/user/guide/config.i18n.yaml | 4 ++-- docs/user/guide/config.md | 6 ++++++ docs/user/guide/config.zh.md | 6 ++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml index a45fbb2186..bf4ccbc63d 100644 --- a/docs/user/guide/config.i18n.yaml +++ b/docs/user/guide/config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/config.md -config.md: c9c760cbdb9650e608809fee4498b7f884b339dc -config.zh.md: b80e31c6f77f51d013aaebe5c4e9e43f5819e33f +config.md: 34a2a902e11fae36b21affc136be70c2e7bd89f9 +config.zh.md: 62d2e35c4501be2cea9d58f2c0d54c3c770e8a08 diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md index c9c760cbdb..34a2a902e1 100644 --- a/docs/user/guide/config.md +++ b/docs/user/guide/config.md @@ -48,6 +48,12 @@ A minimal configuration is a list of plugin entries: Plugins load in file order. Place plugins that depend on services after the applications or capability plugins that provide them. Missing models, tools, and plugins fail as early as possible instead of being silently ignored. +## CLI overlays + +The TUI composes `base.cordis.yml` and `tui.cordis.yml`, then applies one optional patch list. By default that final list is `~/.dsh/config.yaml`; `dsh --config ` replaces the personal list with the named overlay. `dsh --config-replace ` instead boots the named file as the complete tree, without shipped or personal layers. `dsh web --config ` adds its overlay after the shared base and Web surface defaults and before Web profile and CLI-flag patches. + +A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKey` and `baseURL`, so restate every key the row must retain. + ## JavaScript values and environment variables The Cordis loader evaluates runtime expressions tagged with `!!js`. Keep API keys and other secrets in the gitignored `.env` file at the repository root, never in committed configuration. diff --git a/docs/user/guide/config.zh.md b/docs/user/guide/config.zh.md index b80e31c6f7..62d2e35c45 100644 --- a/docs/user/guide/config.zh.md +++ b/docs/user/guide/config.zh.md @@ -48,6 +48,12 @@ Harness 使用 `cordis.yml` 描述 Agent 加载哪些插件以及每个插件的 插件按文件中的顺序加载。依赖其他服务的插件应该排在提供这些服务的应用或能力插件之后;引用不存在的模型、工具或插件会尽早报错,而不是被静默忽略。 +## CLI 覆盖层 + +TUI 先组合 `base.cordis.yml` 与 `tui.cordis.yml`,再应用一个可选补丁列表。默认的最后一层是 `~/.dsh/config.yaml`;`dsh --config ` 会以指定覆盖替代个人补丁列表。`dsh --config-replace ` 则把指定文件作为完整配置树启动,不使用已交付配置或个人层。`dsh web --config ` 会在共享基础配置与 Web 界面默认值之后、Web profile 与命令行标志补丁之前添加覆盖。 + +补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKey` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。 + ## JavaScript 值和环境变量 Cordis loader 使用 `!!js` 标签读取运行时表达式。API key 等凭据应放在仓库根目录、已被 Git 忽略的 `.env` 中,不能提交到配置文件。 From f6a16e48f27cfe99f0924885c29e52066be91c29 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:49:20 +0800 Subject: [PATCH 38/77] docs(cli): record restored web and index behavior --- .../2026-07-29-shared-base-config-overlays.i18n.yaml | 4 ++-- .../2026-07-29-shared-base-config-overlays.md | 6 +++--- .../2026-07-29-shared-base-config-overlays.zh.md | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml index ac8a924568..bd8e785272 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md -2026-07-29-shared-base-config-overlays.md: c3c1bd962dd61201958053d1f1c04e05646f78bd -2026-07-29-shared-base-config-overlays.zh.md: dcf7eaafa7f46b8c2fc1a45e627d31770ba3b234 +2026-07-29-shared-base-config-overlays.md: 006cc6b08f41648f6a0cd0512cd51fc26af96aab +2026-07-29-shared-base-config-overlays.zh.md: 0bc95c78d1e13c2d57f54e1719c07a4823d2b1d3 diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md index c3c1bd962d..006cc6b08f 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md @@ -40,13 +40,13 @@ A patch replaces its target row's whole `config` rather than merging, which shap An overlay or `--config` tree that named `@deepseek-ai/dsh-tui-demo`, or patched the `tui-agent` row, no longer resolves. Overlays now patch the row that owns each key: the model route on `agent-loop`, the persona on `system-prompt`, presentation on `tui`. -A patch whose `id` matches no row stays a Loader warning rather than an error. That is deliberate: one personal overlay is shared across surfaces, and `insert` rows match nothing by design, so a row that exists only under `web` must not fail the TUI's boot. +A patch whose `id` matches no row stays a no-op rather than an error. That is deliberate: one personal overlay is shared across surfaces, and `insert` rows match nothing by design, so a row that exists only under `web` must not fail the TUI's boot. -`dsh web` gains `--config`, threaded into `AppCLIEntry` as an extra overlay. `AppCLIEntry` reads both the base and its surface overlay when recovering row defaults for its own patch merge, since a flag override must preserve the overlay's other fields on the same row. +`dsh web` gains `--config`, threaded into `AppCLIEntry` as an extra overlay. Web keeps sandboxed Bash and filesystem providers plus approval, permission presets, directory picking, and browser permission UI; the overlay disables the shared local providers because patches can disable rows but cannot delete them. The TUI query index uses a unique process-local temporary database because the SQLite backend requires one writer owner. It is a disposable derived index rebuilt by each process; `/resume` lists the underlying corpus directly and does not depend on index reuse. `AppCLIEntry` reads both the base and its surface overlay when recovering row defaults for its own patch merge, since a flag override must preserve the overlay's other fields on the same row. ## Verification -Composition is checked by booting each tree through the real Loader and inspecting settled entries, not by reading YAML: the TUI settles 55 entries and web 75, both with zero unloaded or unsettled rows, and web's `httpServer` up. The three-layer case (`base` + `tui` + `code-mode`) confirms `tools.mode` reaching `code` over the TUI overlay's `native`. +Composition is checked by booting each tree through the real Loader and inspecting settled entries, not by reading YAML; both surfaces settle with zero unloaded rows, and Web starts its `httpServer` with sandboxed Bash and filesystem providers. The three-layer case (`base` + `tui` + `code-mode`) confirms `tools.mode` reaching `code` over the TUI overlay's `native`. All eight terminal snapshot scenarios replay byte-identically after moving, and the 14-case PTY smoke passes, including two cases that assert a personal overlay reaches an **inserted** row — the behavior the vendored `plugin-include` fix enables ([`vendor/README.md`](../../../../vendor/README.md) local modification 8, covered by `packages/ui/app-boot/tests/config-reload.spec.ts`). diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md index dcf7eaafa7..0bc95c78d1 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md @@ -42,11 +42,11 @@ patch 会整体替换目标配置项的 `config` 而不合并,这决定了拆 若某个 patch 的 `id` 不匹配任何配置项,Loader 仍只告警而不报错。这是有意为之:同一份个人 overlay 会跨 surface 共用,而 `insert` 配置项按设计本就不匹配任何目标,因此仅在 `web` 下存在的配置项不能让 TUI 启动失败。 -`dsh web` 新增 `--config`,作为一份额外 overlay 传入 `AppCLIEntry`。`AppCLIEntry` 在为自身 patch 合并恢复配置项默认值时会同时读取 base 与其 surface overlay,因为 flag 覆盖必须保留同一配置项上 overlay 的其他字段。 +`dsh web` 新增 `--config`,作为一份额外 overlay 传入 `AppCLIEntry`。Web 保留沙箱化 Bash 与文件系统提供方,以及审批、权限预设、目录选择和浏览器权限界面;覆盖层会禁用共享的本地提供方,因为补丁可以禁用条目但不能删除条目。TUI 查询索引使用进程唯一的临时数据库,因为 SQLite 后端要求单写入者所有权。该索引是每个进程重新构建的可丢弃派生数据;`/resume` 直接列出底层语料,不依赖索引复用。`AppCLIEntry` 在为自身 patch 合并恢复配置项默认值时会同时读取 base 与其 surface overlay,因为 flag 覆盖必须保留同一配置项上 overlay 的其他字段。 ## 验证 -组合的正确性通过用真实 Loader 启动每棵树并检查已就绪的条目来核对,而不是靠阅读 YAML:TUI 就绪 55 个条目、web 就绪 75 个,两者都没有未加载或未就绪的配置项,且 web 的 `httpServer` 已启动。三层叠加的情形(`base` + `tui` + `code-mode`)确认 `tools.mode` 越过 TUI overlay 的 `native` 达到了 `code`。 +组合的正确性通过用真实 Loader 启动每棵树并检查已就绪的条目来核对,而不是靠阅读 YAML:两个界面都能稳定完成且没有未加载项;Web 会以沙箱化 Bash 与文件系统提供方启动 `httpServer`。三层叠加的情形(`base` + `tui` + `code-mode`)确认 `tools.mode` 越过 TUI overlay 的 `native` 达到了 `code`。 全部八个终端快照场景在迁移后逐字节重放一致,14 个用例的 PTY 冒烟测试全部通过,其中两个用例断言个人 overlay 能触达一个 **insert 进来的**配置项——这正是 vendored `plugin-include` 修复所启用的行为([`vendor/README.md`](../../../../vendor/README.md) 本地修改第 8 条,由 `packages/ui/app-boot/tests/config-reload.spec.ts` 覆盖)。 From dfca95e93f84309e7c5b5cedf275bc11a54ef230 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:51:22 +0800 Subject: [PATCH 39/77] fix(tui): remove disposable query index on exit --- apps/cli/src/tui.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index e96f9b515e..9fcc3336d9 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -18,6 +18,7 @@ */ import { randomUUID } from 'node:crypto' +import { rm } from 'node:fs/promises' import { join, resolve } from 'node:path' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' @@ -226,6 +227,7 @@ export async function runTui( ? loadPersonalPatches(NAME) ?? [] : loadOverlayPatches(NAME, resolveConfigPath(resolve(config), undefined)), ] + const queryIndexPath = join(tmpdir(), SESSION_QUERY_DB) const ctx = await boot( NAME, replaceTree ? resolveConfigPath(resolve(configReplace), undefined) : BASE_CONFIG, @@ -246,7 +248,14 @@ export async function runTui( hostCtx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, { [MAIN_AGENT_ID]: identity }) // The query database is a disposable derived index with single-process // ownership. Keep it process-local while it indexes the shared logs. - hostCtx.provide(SESSION_QUERY_SQLITE_PATH_KEY, join(tmpdir(), SESSION_QUERY_DB)) + hostCtx.provide(SESSION_QUERY_SQLITE_PATH_KEY, queryIndexPath) + hostCtx.effect(() => async () => { + await Promise.all([ + rm(queryIndexPath, { force: true }), + rm(`${queryIndexPath}-wal`, { force: true }), + rm(`${queryIndexPath}-shm`, { force: true }), + ]) + }, 'launcherSessionQueryPath.cleanup') if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost) // Seed the first turn only for a fresh session, so resuming never // re-invokes the skill. From 915121ff407deac81ec0b2335f7209095f400712 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:53:38 +0800 Subject: [PATCH 40/77] docs(cli): remove stale source config ownership --- .../2026-07-28-dsh-native-typescript-source-launch.i18n.yaml | 4 ++-- .../2026-07-28-dsh-native-typescript-source-launch.md | 4 ++-- .../2026-07-28-dsh-native-typescript-source-launch.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml index e10d181359..9f165ccbf1 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md -2026-07-28-dsh-native-typescript-source-launch.md: e128ae1a39c6a3838d948b40936dca6e30d324e1 -2026-07-28-dsh-native-typescript-source-launch.zh.md: 5ff1d975f8012e7d831bc3ecf6e5c53a9d43151f +2026-07-28-dsh-native-typescript-source-launch.md: 2e69a93f74bb056ce654086641c3123c499a3ffb +2026-07-28-dsh-native-typescript-source-launch.zh.md: 8be7f8527de8b9ff4eaadac9af3137fca8d153ac diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md index e128ae1a39..2e69a93f74 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md @@ -20,9 +20,9 @@ The `dsh` TUI, Web, and headless source launches use `node --experimental-transf `scripts/tspath-loader.ts` registers only a module resolve hook. It uses `TSX_TSCONFIG_PATH` when set (resolving relative values from the invoking cwd) and otherwise reads the root `tsconfig.json`; `TsconfigPathsResolver` follows that config's `extends` chain through the repository's existing TypeScript development tool, selects exact or wildcard `paths` entries according to tsconfig rules, and maps matching workspace bare specifiers to `.ts`/`.mts`/`.cts` source files or directory index files. Node remains solely responsible for code transformation. The source-only loader is not part of the built CLI and `apps/cli` does not declare `typescript` as a runtime dependency. -Source imports are redirected only when the target package is either the nearest package manifest's own name or one of that manifest's declared runtime dependencies. The Cordis Loader uses the configuration directory URL as the import parent; the resolver then searches upward for the workspace manifest that declares the plugin, so dependency ownership for `examples/tui-agent/cordis.yml` lies with `examples/package.json`, and dependency ownership for `apps/cli/base.cordis.yml` plus its surface overlay lies with `apps/cli/package.json`. Specifiers that do not match tsconfig paths, refer to undeclared dependencies, or are not bare all fall back to Node's default resolution. +Source imports are redirected only when the target package is either the nearest package manifest's own name or one of that manifest's declared runtime dependencies. The Cordis Loader uses the configuration directory URL as the import parent; the resolver then searches upward for the workspace manifest that declares the plugin, so dependency ownership for the shipped `apps/cli/base.cordis.yml` plus its surface overlay lies with `apps/cli/package.json`. Specifiers that do not match tsconfig paths, refer to undeclared dependencies, or are not bare all fall back to Node's default resolution. -`verify-cordis-config` performs a one-way completeness check on both resolver manifests: every bare plugin package in a configuration must appear in the corresponding manifest's `dependencies`, while the manifest may contain extra dependencies not referenced by that configuration. The root `AGENTS.md` makes updating the configuration and dependencies together a standing rule. +`verify-cordis-config` performs a one-way completeness check on the resolver manifest: every bare plugin package in a configuration must appear in the corresponding manifest's `dependencies`, while the manifest may contain extra dependencies not referenced by that configuration. The root `AGENTS.md` makes updating the configuration and dependencies together a standing rule. After the Loader settles, the shared `dsh-app-boot` checks every enabled entry that has no fiber and rejects startup with `plugin(s) failed to load: ...; Cordis startup failed because these plugin(s) could not be resolved`, listing all failed plugins. This diagnostic lives at the app layer and does not change the vendored Loader's startup behavior. diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md index 5ff1d975f8..8be7f8527d 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md @@ -20,9 +20,9 @@ Cordis 配置还引入了另一条解析边界。`cordis.yml` 中的 bare plugin `scripts/tspath-loader.ts` 只注册一个模块 resolve hook。设置 `TSX_TSCONFIG_PATH` 时,它会使用该路径(相对路径从调用方的 cwd 解析),否则读取根 `tsconfig.json`;`TsconfigPathsResolver` 使用仓库已有的 TypeScript 开发工具沿该配置的 `extends` 链解析,按 tsconfig 规则选择精确或 wildcard `paths` 条目,并将命中的 workspace bare specifier 映射到 `.ts`/`.mts`/`.cts` 源文件或目录 index 文件。代码转换始终只由 Node 负责。该源码专用 loader 不属于构建后的 CLI,`apps/cli` 也不会把 `typescript` 声明为运行时依赖。 -只有当目标包是最近 package manifest 的自身名称或其已声明的运行时依赖时,源码 import 才会重定向。Cordis Loader 使用配置目录 URL 作为 import parent;此时 resolver 会向上查找声明该插件的 workspace manifest。因此,`examples/tui-agent/cordis.yml` 的依赖由 `examples/package.json` 持有,`apps/cli/base.cordis.yml` plus its surface overlay 的依赖由 `apps/cli/package.json` 持有。未命中 tsconfig paths、引用未声明依赖或不是 bare specifier 的说明符全部交回 Node 默认解析。 +只有当目标包是最近 package manifest 的自身名称或其已声明的运行时依赖时,源码 import 才会重定向。Cordis Loader 使用配置目录 URL 作为 import parent;此时 resolver 会向上查找声明该插件的 workspace manifest。因此,已交付的 `apps/cli/base.cordis.yml` 及其界面覆盖层所需依赖由 `apps/cli/package.json` 持有。未命中 tsconfig paths、引用未声明依赖或不是 bare specifier 的说明符全部交回 Node 默认解析。 -`verify-cordis-config` 对这两个解析方 manifest 执行单向完整性检查:配置中的每个 bare plugin package 都必须出现在对应 manifest 的 `dependencies` 中,manifest 可以包含该配置未引用的额外依赖。根 `AGENTS.md` 将同步更新配置和依赖定为常驻规则。 +`verify-cordis-config` 对该解析方 manifest 执行单向完整性检查:配置中的每个 bare plugin package 都必须出现在对应 manifest 的 `dependencies` 中,manifest 可以包含该配置未引用的额外依赖。根 `AGENTS.md` 将同步更新配置和依赖定为常驻规则。 Loader 完成结算后,共享的 `dsh-app-boot` 会检查每个已启用但没有 fiber 的 entry,并以 `plugin(s) failed to load: ...; Cordis startup failed because these plugin(s) could not be resolved` 拒绝启动,同时列出全部加载失败的插件。该诊断位于应用层,不改变 vendor 中 Loader 的启动行为。 From 87c5c46249d59dcd9b49ff8637cb282fc9273174 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:54:51 +0800 Subject: [PATCH 41/77] docs(i18n): remove unrelated terminology --- docs/i18n/terminology.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index 74392c18ef..5072c2684d 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -116,7 +116,6 @@ | extension | 扩展 | | | | | extension point | 扩展点 | | | 注意与 `seam` 区分 | | fail-fast | 快速失败 | | | | -| fail-open | 故障放行 | 故障放行(fail-open) | 故障开放 | 与 `fail-fast` 对称;指无法判定时放行而非阻断 | | fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 | | fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash | | finish reason | 结束原因 | | | | From 2994342f2317c627e8193e5aa0f23ed8be8ae5b7 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:56:55 +0800 Subject: [PATCH 42/77] docs(web): align browser roster composition --- .../feature/2026-07-24-web-session-model-selector.i18n.yaml | 4 ++-- .../feature/2026-07-24-web-session-model-selector.md | 2 +- .../feature/2026-07-24-web-session-model-selector.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml index 55b9d222b4..1b4365fdcf 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md -2026-07-24-web-session-model-selector.md: bf2f1f3c677ef576b1a00fb8bd0133154462d705 -2026-07-24-web-session-model-selector.zh.md: b2e17bdf487c717dc85e9b16b8f8e526557cdfb9 +2026-07-24-web-session-model-selector.md: 7e0355e272c6b5c62df5eb50acce7bc5de5d9795 +2026-07-24-web-session-model-selector.zh.md: d5cd7eb7e679901fcd62945af7b19ede42e74a9d diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md index bf2f1f3c67..7e0355e272 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md @@ -18,7 +18,7 @@ The browser `ModelService` owns one `ModelDirectory` per live session. Its snaps `@deepseek-ai/dsh-client-ui-conversation` declares the session-scoped single slot `conversation.input.model` as a child of its composer-bar entry. InputBar renders the seat in its trailing controls immediately before the pending indicator and primary button; the seat receives the bar's `locked` owner prop and session scope. `@deepseek-ai/dsh-client-ui-model` occupies that seat and also contributes `/model` over the same directory. Its compact trigger displays the catalog model name and effective reasoning label, falling back to ids when metadata is absent. The upward menu first offers Model and, when the current exact model supports it, Effort; Model drills into provider groups, while Effort drills into the adapter-ordered levels. The provider-default row appears only when the adapter does not configure a model default. -The production browser roster is the flat config tree in `apps/cli/web.cordis.yml`; the model feature is one `dshClient` row rather than a package hardcoded in Web boot code. Its package manifest orders it after the runtime and command feature, while Cordis service injection waits for the conversation slot before registering the composer occupant. +The production browser roster is assembled from `apps/cli/base.cordis.yml` plus `apps/cli/web.cordis.yml`; the model feature is one `dshClient` row rather than a package hardcoded in Web boot code. Its package manifest orders it after the runtime and command feature, while Cordis service injection waits for the conversation slot before registering the composer occupant. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md index b2e17bdf48..d5cd7eb7e6 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md @@ -18,7 +18,7 @@ Web Host 为每个新建或恢复的 agent(智能体)复用 `installAgentLlm `@deepseek-ai/dsh-client-ui-conversation` 将会话作用域的单实例 slot `conversation.input.model` 声明为其输入栏 entry 的子 slot。InputBar 在尾部控件区将该 seat 渲染于 pending 指示器与主按钮之前;该 seat 接收输入栏的 `locked` owner prop 与会话作用域。`@deepseek-ai/dsh-client-ui-model` 占用该 seat,并在同一目录上提供 `/model`。其紧凑型触发器显示目录中的模型名称与生效的推理强度标签;元数据缺失时则回退到相应 ID。向上展开的菜单首先提供 Model,并在当前精确模型支持时提供 Effort;Model 可深入提供方分组,Effort 可深入适配器排序的级别。仅当适配器没有配置模型默认值时,才显示提供方默认值行。 -生产环境的浏览器名册是 `apps/cli/base.cordis.yml` plus its surface overlay 中的平铺 config tree;模型功能对应其中一行 `dshClient` 配置项,而不是 Web boot 代码中硬编码的包。其包 manifest(元数据清单)将加载顺序置于运行时与命令功能之后;Cordis 服务注入则等待 conversation slot 可用,再注册 composer 占用方。 +生产环境的浏览器名册由 `apps/cli/base.cordis.yml` 与 `apps/cli/web.cordis.yml` 共同组装;模型功能对应其中一行 `dshClient` 配置项,而不是 Web boot 代码中硬编码的包。其包 manifest(元数据清单)将加载顺序置于运行时与命令功能之后;Cordis 服务注入则等待 conversation slot 可用,再注册 composer 占用方。 ## 考虑过的替代方案 From 3d446da9c713a024399d333c41c3b2e9b1feea4a Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:57:54 +0800 Subject: [PATCH 43/77] docs(web): align bilingual composition wording --- .../2026-07-23-client-plugin-loading-model.i18n.yaml | 2 +- .../architecture/2026-07-23-client-plugin-loading-model.zh.md | 2 +- ...7-24-web-config-tree-boot-and-transport-layering.i18n.yaml | 4 ++-- .../2026-07-24-web-config-tree-boot-and-transport-layering.md | 2 +- ...26-07-24-web-config-tree-boot-and-transport-layering.zh.md | 2 +- .../testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 ++-- .../testing/2026-07-24-web-gui-browser-e2e-lane.md | 4 ++-- .../testing/2026-07-24-web-gui-browser-e2e-lane.zh.md | 4 ++-- 8 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index bab12b6785..229a5fb221 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md 2026-07-23-client-plugin-loading-model.md: ef9f14f2a150b0845ff89939bce5f8863dc15707 -2026-07-23-client-plugin-loading-model.zh.md: 0fbcbb0717be9d5aac27c33d9482c7e13bf7f4ea +2026-07-23-client-plugin-loading-model.zh.md: 71dc1e2303b657a3793b4ddd5c263337177ad671 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index 0fbcbb0717..71dc1e2303 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -116,7 +116,7 @@ wire 两侧跑着同一份治理实现;浏览器特有的表面只是一套模 接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;图中 `inject` 行仅是信息性说明——激活的真相在服务层——因此不匹配会在 settled 扫描时浮出,而不是在图校验时被拦下;三个尚未升格的库在各自的 DI 转换落地之前保持静态 import 的导出面。 -名册的终局(2026-07-25 随配置树 boot 迁移落地):名册住 `apps/cli/base.cordis.yml` plus its surface overlay,`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于换 yml/overlay。图的组合器从 webserver 侧的注册表迁进 `dsh-client-modules` 的 node 半(该包按本 note 的升级法则升格为双面——其消费方现经 cordis DI 到达),传输拆分同轮落地:webserver 变为朴素路由注册插件,`/api/*` 绑定迁到 connection 的 node 半、走升格后的 `api-gateway` 插件(`dsh-host-apiproxy` 提供 `ctx.apiProxy`),dev 的 bundle 监视与 SSE 通道迁到 hmr 的 node 半。 +名册的终局(2026-07-25 随配置树 boot 迁移落地):名册住 `apps/cli/base.cordis.yml` 与 `apps/cli/web.cordis.yml`,`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于换 yml/overlay。图的组合器从 webserver 侧的注册表迁进 `dsh-client-modules` 的 node 半(该包按本 note 的升级法则升格为双面——其消费方现经 cordis DI 到达),传输拆分同轮落地:webserver 变为朴素路由注册插件,`/api/*` 绑定迁到 connection 的 node 半、走升格后的 `api-gateway` 插件(`dsh-host-apiproxy` 提供 `ctx.apiProxy`),dev 的 bundle 监视与 SSE 通道迁到 hmr 的 node 半。 ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index 1d723d4d94..32dd80bff1 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: bf1dd829af73e61f2545c1f9034bc7ffda7042ed -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 03c1b64f3a9a17cb0f607764ea2c1a52b77551a3 +2026-07-24-web-config-tree-boot-and-transport-layering.md: af9d5b4d19c96da186be85e16681ba9c38f2a25a +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 475b546e05ce7d9ce405266585882ffd36a27fad diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index bf1dd829af..af9d5b4d19 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -12,7 +12,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) ## Decision -**Composition is one flat config tree.** `apps/cli/base.cordis.yml` plus its surface overlay holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle sweep — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven, and the boot compensates with a fail-loud triple: `assertEntriesLoaded` (import failures), `installFailLoud` (late apply rejections), and an all-ACTIVE sweep (PENDING fibers — cordis inject waiting has no timeout). +**Composition is one flat assembled tree.** `apps/cli/base.cordis.yml` plus `apps/cli/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle sweep — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven, and the boot compensates with a fail-loud triple: `assertEntriesLoaded` (import failures), `installFailLoud` (late apply rejections), and an all-ACTIVE sweep (PENDING fibers — cordis inject waiting has no timeout). **Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the triple. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index 03c1b64f3a..475b546e05 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -**组合是一棵平铺 config tree。** `apps/cli/base.cordis.yml` plus its surface overlay 持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle sweep 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动,boot 以 fail-loud 三件套补偿:`assertEntriesLoaded`(import 失败)、`installFailLoud`(迟到的 apply 拒绝)、all-ACTIVE sweep(PENDING fiber——cordis inject 等待没有超时)。 +**组合结果是一棵平铺配置树。** `apps/cli/base.cordis.yml` 与 `apps/cli/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle sweep 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动,boot 以 fail-loud 三件套补偿:`assertEntriesLoaded`(import 失败)、`installFailLoud`(迟到的 apply 拒绝)、all-ACTIVE sweep(PENDING fiber——cordis inject 等待没有超时)。 **boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加三件套。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 52f8014d7d..71a866630f 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: 04240456d1df4eab79372de0ebd900f5831cd819 -2026-07-24-web-gui-browser-e2e-lane.zh.md: 6be7056b3d46be875b74c56d93045a37fd3d2c08 +2026-07-24-web-gui-browser-e2e-lane.md: 763c6b4b06e951932626babb7a3f48261a475e1d +2026-07-24-web-gui-browser-e2e-lane.zh.md: 978148fec0c4ae1c177f181802ec980f2cf196a1 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index 04240456d1..763c6b4b06 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -16,7 +16,7 @@ The web GUI ships as a real assembled chain — chromium page → client plugin A plain shared-fixture module (the [testing-policy sanctioned shape](../../../../docs/testing.md)), not a package: the gate-worthy logic — replay derivation, session parsing, log scrubbing, persistence — lives in the gated packages `dsh-llm-replay`, `dsh-acp-snapshot`, and `dsh-session-persistence-jsonl`; what remains is boot wiring and browser glue, and chromium-driving code cannot hold per-file 100% coverage on the browserless coverage runners. -`launchWebScaffold()` boots the real web composition from the shipped `apps/cli/base.cordis.yml` plus its surface overlay through the vendored Loader's include mechanism — the same tree and mechanism `AppCLIEntry` drives for `dsh web`. Divergences ride include patches over that tree, the ACP `cordis.snapshot.yml` pattern expressed in-process: temp `persistenceRoot`, `workspace-context` disabled (recorded fixtures must not embed this repo's AGENTS.md), `session-title-llm` disabled (its fire-and-forget title call would race the loop for the session's replay cursor), the webserver row pinned to port 0 with the built dist, and in keyless modes `llm-deepseek` disabled. A patch id that stops matching a row fails the boot sweep loudly instead of drifting. The boot runs `chdir`'d to the temp workspace so the api-gateway's `process.cwd()` session default, tool cwds, and fixtures agree; the `dsh web` bin's own glue (argv, profile json, AppCLIEntry) stays held by the keyless CLI smokes in `smoke-real.e2e.ts`. Setup rollback and ordinary close both dispose the Cordis tree before removing the two owned temp roots, attempt every cleanup independently, and report cleanup failures without masking the setup failure. +`launchWebScaffold()` boots the real web composition from the shipped `apps/cli/base.cordis.yml` plus `apps/cli/web.cordis.yml` through the vendored Loader's include mechanism — the same tree and mechanism `AppCLIEntry` drives for `dsh web`. Divergences ride include patches over that tree, the ACP `cordis.snapshot.yml` pattern expressed in-process: temp `persistenceRoot`, `workspace-context` disabled (recorded fixtures must not embed this repo's AGENTS.md), `session-title-llm` disabled (its fire-and-forget title call would race the loop for the session's replay cursor), the webserver row pinned to port 0 with the built dist, and in keyless modes `llm-deepseek` disabled. A patch id that stops matching a row fails the boot sweep loudly instead of drifting. The boot runs `chdir`'d to the temp workspace so the api-gateway's `process.cwd()` session default, tool cwds, and fixtures agree; the `dsh web` bin's own glue (argv, profile json, AppCLIEntry) stays held by the keyless CLI smokes in `smoke-real.e2e.ts`. Setup rollback and ordinary close both dispose the Cordis tree before removing the two owned temp roots, attempt every cleanup independently, and report cleanup failures without masking the setup failure. Keyless model displacement is the disabled adapter row plus `installLlmReplay` filling the open seam on the settled root ctx in providers-catalog mode — never catch-all: with the adapter row disabled no adapter exists, so catch-all would leave `resolveModelInfo` unroutable and `compact-basic`'s post-step pressure check would warn every step instead of being provably inert (the published 128k `contextWindow` keeps it inert for small fixtures). The direct install rather than an inserted replay plugin row is deliberate: it returns the `ReplayHandle` the teardown consumption check needs. A scenario with no fixture leaves the seam empty, so a stray stream fails loud with NO_ADAPTER. @@ -66,7 +66,7 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot **A committed normalized-session-log golden as a second expected surface.** Rejected: the log surface is pinned by the ACP/headless/TUI suites through the same loop and persistence; here it would double refresh cost and re-test lower tiers. Inline world-state assertions on root-context events keep the world-verification duty. -**Spawning the `dsh web` bin with a `DSH_SNAPSHOT` replay branch.** Rejected: it needs a test-only replay branch plus environment plumbing in the shipped CLI. The in-process scaffold already loads the same `apps/cli/base.cordis.yml` plus its surface overlay; only argv, profile JSON, and `AppCLIEntry` glue remain outside it, and the keyless CLI smokes cover those paths. +**Spawning the `dsh web` bin with a `DSH_SNAPSHOT` replay branch.** Rejected: it needs a test-only replay branch plus environment plumbing in the shipped CLI. The in-process scaffold already loads the same `apps/cli/base.cordis.yml` plus `apps/cli/web.cordis.yml`; only argv, profile JSON, and `AppCLIEntry` glue remain outside it, and the keyless CLI smokes cover those paths. **Changing the wire protocol for testability.** Rejected: the contract already has a first-class keyless isomorphic seam (`InProcessApiClient(toFetchHandler(api))`), the per-event unbatched SSE is exactly what makes replay observable in a browser, and testing a wire we no longer ship would invert the tier's purpose. diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 6be7056b3d..978148fec0 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -16,7 +16,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu 一个普通的共享 fixture 模块([测试政策认可的形态](../../../../docs/testing.md)),不是包:值得门禁把守的逻辑——回放推导、会话解析、日志脱敏、持久化——都在已受门禁的包 `dsh-llm-replay`、`dsh-acp-snapshot`、`dsh-session-persistence-jsonl` 中;剩下的只是启动接线和浏览器胶水,而驱动 chromium 的源码在无浏览器的覆盖率 runner 上无法诚实保持逐文件 100% 覆盖率。 -`launchWebScaffold()` 通过 vendored Loader 的 include 机制,从交付的 `apps/cli/base.cordis.yml` plus its surface overlay 启动真实 web 组合——与 `AppCLIEntry` 为 `dsh web` 驱动的是同一棵树、同一套机制。差异全部经 include patch 覆盖在这棵树上,即 ACP `cordis.snapshot.yml` 模式的进程内表达:临时 `persistenceRoot`;禁用 `workspace-context`(录制的 fixture 不得嵌入本仓库的 AGENTS.md);禁用 `session-title-llm`(其发后不管的标题调用会与循环争抢会话的回放游标);webserver 行钉到端口 0 加已构建 dist;无密钥模式下禁用 `llm-deepseek`。patch 的 id 一旦不再匹配任何行,boot 扫描会大声失败而不是漂移。boot 在临时工作区 `chdir` 下运行,使 api-gateway 的 `process.cwd()` 会话默认值、工具 cwd 与 fixture 一致;`dsh web` bin 自身的胶水(argv、profile json、AppCLIEntry)仍由 `smoke-real.e2e.ts` 中的无密钥 CLI 冒烟把守。初始化回滚和正常关闭都会先对 Cordis 树执行 dispose(资源释放),再删除 scaffold 持有的两个临时根目录;每项清理都会独立尝试,并会报告清理失败而不掩盖初始化失败。 +`launchWebScaffold()` 通过 vendored Loader 的 include 机制,从交付的 `apps/cli/base.cordis.yml` 与 `apps/cli/web.cordis.yml` 启动真实 web 组合——与 `AppCLIEntry` 为 `dsh web` 驱动的是同一棵树、同一套机制。差异全部经 include patch 覆盖在这棵树上,即 ACP `cordis.snapshot.yml` 模式的进程内表达:临时 `persistenceRoot`;禁用 `workspace-context`(录制的 fixture 不得嵌入本仓库的 AGENTS.md);禁用 `session-title-llm`(其发后不管的标题调用会与循环争抢会话的回放游标);webserver 行钉到端口 0 加已构建 dist;无密钥模式下禁用 `llm-deepseek`。patch 的 id 一旦不再匹配任何行,boot 扫描会大声失败而不是漂移。boot 在临时工作区 `chdir` 下运行,使 api-gateway 的 `process.cwd()` 会话默认值、工具 cwd 与 fixture 一致;`dsh web` bin 自身的胶水(argv、profile json、AppCLIEntry)仍由 `smoke-real.e2e.ts` 中的无密钥 CLI 冒烟把守。初始化回滚和正常关闭都会先对 Cordis 树执行 dispose(资源释放),再删除 scaffold 持有的两个临时根目录;每项清理都会独立尝试,并会报告清理失败而不掩盖初始化失败。 无密钥的模型替换 = 禁用适配器行的 patch 加 `installLlmReplay` 在停稳的根 ctx 上以提供方目录(providers-catalog)模式填充开放的 seam——绝不用 catch-all:适配器行被禁用后不存在任何适配器,catch-all 会让 `resolveModelInfo` 无路由可走,`compact-basic` 的步后压力检查将步步告警,而不是被可证明地闲置(发布的 128k `contextWindow` 使该路径对小 fixture 保持闲置)。选择直接安装而非插入回放插件行是刻意的:直接安装返回收尾消费检查所需的 `ReplayHandle`。没有 fixture 的场景让 seam 保持空置,任何离群的流式调用都会以 NO_ADAPTER 大声失败。 @@ -66,7 +66,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu **第二份提交的规范化会话日志预期输出。** 已否决:日志表面已由 ACP/headless/TUI 套件经同一循环与持久化钉住;在此只会翻倍刷新成本并重复测试下层。内联在根上下文事件上的世界状态断言保住了验证世界的义务。 -**以 `DSH_SNAPSHOT` 回放分支拉起 `dsh web` bin。** 已否决:它需要在交付的 CLI 中增加测试专用回放分支和环境变量管道。进程内 scaffold 已加载同一份 `apps/cli/base.cordis.yml` plus its surface overlay;只剩 argv、profile JSON 和 `AppCLIEntry` 胶水不在其覆盖范围内,而这些路径已由无密钥 CLI 冒烟覆盖。 +**以 `DSH_SNAPSHOT` 回放分支拉起 `dsh web` bin。** 已否决:它需要在交付的 CLI 中增加测试专用回放分支和环境变量管道。进程内 scaffold 已加载同一份 `apps/cli/base.cordis.yml` 与 `apps/cli/web.cordis.yml`;只剩 argv、profile JSON 和 `AppCLIEntry` 胶水不在其覆盖范围内,而这些路径已由无密钥 CLI 冒烟覆盖。 **为可测试性改 wire 协议。** 已否决:契约已有第一等的无密钥同构 seam(`InProcessApiClient(toFetchHandler(api))`),逐事件不合批的 SSE 恰是回放在浏览器中可观测的原因,测试一条不再交付的 wire 会颠倒该层的存在意义。 From 3d3a2617932edf52c07f6ba9259d8d4e67994231 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:59:44 +0800 Subject: [PATCH 44/77] docs(cli): remove stale composition references --- apps/cli/README.i18n.yaml | 4 ++-- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/src/app-cli-entry.ts | 4 ++-- apps/cli/src/args.ts | 4 ++-- apps/cli/src/headless.ts | 2 +- apps/cli/src/tui.ts | 7 ++----- apps/cli/src/web.ts | 4 ++-- apps/cli/tests/args.spec.ts | 4 ++-- apps/cli/tests/built-bin.e2e.ts | 2 +- apps/cli/tests/sessions-root.spec.ts | 2 +- apps/cli/tests/tui.snapshot.ts | 2 +- 12 files changed, 18 insertions(+), 21 deletions(-) diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 80c388280b..3b54366fab 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 7cde0dd8c9c6cf794cf8d1676ed6938e204117cf -README.zh.md: 3b21d563cbd8458810cd05f1ef71bd88074f294f +README.md: 25f201a082961db9cd4760334d2ab07a13deab89 +README.zh.md: f288bf61b77e5fe5cfdafafdc1fdfbf9f5669582 diff --git a/apps/cli/README.md b/apps/cli/README.md index 7cde0dd8c9..25f201a082 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -3,7 +3,7 @@ English | [中文](README.zh.md) -Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`), whose `meta` subcommand is the same TUI over this checkout, whose `upgrade` subcommands are option-less guided-session entries, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `migrate`, `upgrade`, `web` — rejects a leaked `--config`/`-p`/`--resume` rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. +Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`), whose `meta` subcommand is the same TUI over this checkout, whose `upgrade` subcommand is an option-less guided-session entry, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `upgrade`, `web`, `meta` — rejects a leaked `--config`/`-p`/`--resume` rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped Web overlay value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. The TUI surface: diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 3b21d563cb..f288bf61b7 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -3,7 +3,7 @@ [English](README.md) | 中文 -Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`upgrade` 子命令是无选项的引导会话入口,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`migrate`、`upgrade`、`web`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 +Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`upgrade` 子命令是无选项的引导会话入口,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`upgrade`、`web`、`meta`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 Web 覆盖层值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 TUI 界面: diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index c0e67bae00..ab86f1f77f 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -1,8 +1,8 @@ /** * AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share - * (`dsh web` and `dsh -p` boot the one composition; TUI migrates later). + * for the Web/headless surface. * Everything here is what must exist before the Loader runs: layered env, - * the patch composition over the shipped cordis.yml (profile json + CLI + * the patch composition over the shipped base and surface overlay (profile json + CLI * flags + the resolved frontend dist), and the fail-loud triple after the * tree settles. */ diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index ca898b08ea..f5600ff610 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -49,7 +49,7 @@ interface SkillSessionInvocation { * passed — pass-through overrides with no CLI default and no CLI validation: * the `dsh-host-webserver` schema (`host` a loopback/all-interfaces literal, * `port` a natural ≤ 65535) is the single source of both the default (the - * shipped `cordis.yml` value stands when a flag is absent) and validity (a bad + * shipped Web overlay value stands when a flag is absent) and validity (a bad * value fails loud at boot). `port` is `Number`-coerced only because the schema * wants a number, not a string. `dev` mounts the client HMR driver; * `workspaceRoot` is the parent directory for name-created workspaces. @@ -185,7 +185,7 @@ Examples: }) // Host and port name no default: the CLI passes neither through when the flag - // is absent, so the shipped `cordis.yml` value stands and restating it here + // is absent, so the shipped Web overlay value stands and restating it here // would duplicate a fact this file does not own. const web = program.command('web').description('serve the browser UI on the configured host and port') web diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 8007b30d44..41381a3616 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -1,6 +1,6 @@ /** * `dsh -p "task"` — headless over the one shared composition: AppCLIEntry - * boots the same cordis.yml as `dsh web` (port 0, so parallel runs never + * boots the same base plus Web overlay as `dsh web` (port 0, so parallel runs never * collide), then in-process isomorphic injection (InProcessApiClient over * toFetchHandler(ctx.apiProxy), so the full carrier chain — wire * serialization, zod, SSE framing — really runs). The printed URL opens the diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 9fcc3336d9..f1324d8b26 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -1,6 +1,6 @@ /** * `dsh` default surface — the interactive TUI coding agent. Boots the shipped - * tui-agent config (or the `--config` override) with the personal overlay + * shared base and TUI overlay, followed by either `--config` or the personal overlay * from the Harness home (`~/.dsh`): its `.env` fills environment gaps (precedence: * ambient environment, then the invoking directory's `.env`, then the personal one) * and its `config.yaml` patches the booted tree. The workspace is the invoking @@ -47,9 +47,6 @@ import { const NAME = 'dsh' -// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit -// one directory under apps/cli, so the shipped default config resolves with -// the same relative hop from either artifact. // The shared core every `dsh` surface mounts, and the TUI's own overlay over // it. Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) // sit one directory under apps/cli, so each resolves with the same hop. @@ -81,7 +78,7 @@ export function launcherSessionsRoot(): string { } /* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers; - the tui-agent PTY smoke drives this path end to end, personal overlay included */ + the CLI PTY smoke drives this path end to end, personal overlay included */ /** * Run the interactive TUI with this harness checkout as the workspace * (`dsh meta`), whatever directory it was launched from. diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 85f79d5fc4..d6e0c159d4 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -1,7 +1,7 @@ /** * `dsh web` — thin bin over the config-tree boot: run AppCLIEntry with the * already-parsed host/port/dev, print the URL line, wire signals. All - * composition lives in cordis.yml; all boot glue lives in AppCLIEntry. Host and + * composition lives in the shared base plus Web overlay; all boot glue lives in AppCLIEntry. Host and * port are unvalidated pass-through overrides — the `dsh-host-webserver` schema * gates them at boot. */ @@ -20,7 +20,7 @@ const LOOPBACK_HOST = '127.0.0.1' /** * Serve the browser UI from the shipped config tree. `host`/`port` are passed - * through only when the flag was given; absent, the `cordis.yml` value stands. + * through only when the flag was given; absent, the shipped Web overlay value stands. * @param host - the bind host, or `undefined` to keep the config default. * @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default. * @param dev - mount the client HMR driver and watch plugin bundles for rebuilds. diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index ccbd5fa326..6e7211694c 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -33,7 +33,7 @@ describe('parseDshArgs', () => { expect(parse(['meta'])).toEqual({ mode: 'meta' }) // Credential setup is option-free: it writes the Harness-home .env, so // there is nothing for a flag to select. - // Bare `web` carries no host/port: the shipped cordis.yml owns the default. + // Bare `web` carries no host/port: the shipped Web overlay owns the default. expect(parse(['web'])).toEqual({ mode: 'web', dev: false }) // Host/port are unvalidated pass-throughs (the webserver schema gates them // at boot); the adapter only coerces the port string to a number. @@ -64,7 +64,7 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '--resume', 's'])).toBe(1) expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1) expect(exitCode(['--config-replace', 'tree.yml', 'web'])).toBe(1) - // Same rule for credential setup: it shares no option with the default + // Same rule for each subcommand that shares no option with the default // surface, so a leaked flag is a typo, not something to ignore. // `meta` fixes its own config tree and always starts fresh, so every // default-surface option is rejected. diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 1ea7d9f0db..d5bcbfd378 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -16,7 +16,7 @@ import { describe, expect, it } from 'vitest' * node_modules, so no external consumer is assembled; missing-config fail-loud * and full-boot coverage for the shared dsh-app-boot glue live in cli-demo's * built-bin suite, and interactive TTY behavior is PTY-covered by - * examples/tui-agent. Skips before the bin is built. + * apps/cli/tests. Skips before the bin is built. */ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) diff --git a/apps/cli/tests/sessions-root.spec.ts b/apps/cli/tests/sessions-root.spec.ts index 8f12455f1b..cb9982615a 100644 --- a/apps/cli/tests/sessions-root.spec.ts +++ b/apps/cli/tests/sessions-root.spec.ts @@ -3,7 +3,7 @@ * its opaque `SESSIONS_ROOT_KEY` boot-slot value to `DSH_HOME/sessions`. The * plugin side — the slot treated as opaque, explicit config winning, and a * project-local fallback with no globality assumption — is pinned by - * `packages/examples/tui-demo/tests/tui-agent.spec.ts`. + * the former bundled TUI tests. */ import { join, resolve } from 'node:path' diff --git a/apps/cli/tests/tui.snapshot.ts b/apps/cli/tests/tui.snapshot.ts index af7c027d6d..f841c817e6 100644 --- a/apps/cli/tests/tui.snapshot.ts +++ b/apps/cli/tests/tui.snapshot.ts @@ -53,7 +53,7 @@ interface Scenario { recorded: boolean seedWorkspace?: boolean /** - * Load the opt-in `todo_write` tool for this scenario. The shipped tui-agent + * Load the opt-in `todo_write` tool for this scenario. The shipped TUI * config omits it, so only the todo-plan scenario (the enabled-path proof) * mounts it; the rest cover the default, todo-free composition. */ From 6b2ed3e24dfe3493ec865b84e014f9b22a0b6f49 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 22:06:54 +0800 Subject: [PATCH 45/77] test(tui): scope unreachable resume coverage branches --- packages/ui/tui/src/chat/resume.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ui/tui/src/chat/resume.ts b/packages/ui/tui/src/chat/resume.ts index 6e90101bb8..18f7944fc2 100644 --- a/packages/ui/tui/src/chat/resume.ts +++ b/packages/ui/tui/src/chat/resume.ts @@ -80,9 +80,10 @@ 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 */ 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( @@ -111,9 +112,10 @@ 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 */ 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 query.listSessions()).find(candidate => candidate.header.id === sessionId) From 5b35132e62048a296f214387bf7fcb9dd88e7c84 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 22:27:30 +0800 Subject: [PATCH 46/77] test(cli): allow artifact PTY startup contention --- apps/cli/tests/tui-keyless-smoke.e2e.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 289f716f6a..fdef19b824 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -132,6 +132,8 @@ function smoke(overrides: Partial & { label: string }): Prom binScript: dshBinScript, tsconfigPath, env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call' }, + // Artifact CI builds and smokes concurrently on a contended runner. + ...(process.env.DSH_EXAMPLE_MODE === 'lib' ? { timeoutMs: 60_000 } : {}), ...overrides, }) } From d2270eefcdff77b72a6bdee1909dcc35d5ee58f2 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 23:36:58 +0800 Subject: [PATCH 47/77] fix(cli): align personal overlay semantics --- apps/cli/README.i18n.yaml | 4 ++-- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/src/app-cli-entry.ts | 10 +++++----- apps/cli/src/web.ts | 3 ++- 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 3b54366fab..7e4aacef29 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 25f201a082961db9cd4760334d2ab07a13deab89 -README.zh.md: f288bf61b77e5fe5cfdafafdc1fdfbf9f5669582 +README.md: 2bc36cce6205a4bfc3ba1d7ee15f0e0b2feab215 +README.zh.md: 0e0771658cecb4bee0f3eadd0639ad531e64ea3c diff --git a/apps/cli/README.md b/apps/cli/README.md index 25f201a082..2bc36cce62 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -18,7 +18,7 @@ The TUI surface: `dsh upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection. -The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`; `dsh web --config ` adds an overlay after the web surface defaults. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). +The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config ` replaces that personal overlay. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index f288bf61b7..0e0771658c 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -18,7 +18,7 @@ TUI 界面: `dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。 -Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`;`dsh web --config ` 会在 Web 界面默认值之后追加一个覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 +Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config ` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。 diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index ab86f1f77f..9f258257b7 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -17,7 +17,7 @@ import type { FiberState } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include, { type PatchOptions } from '@cordisjs/plugin-include' import yaml from 'js-yaml' -import { assertEntriesLoaded, installFailLoud, loadEnv, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import { assertEntriesLoaded, installFailLoud, loadEnv, loadOverlayPatches, loadPersonalPatches } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome, resolveSessionsRoot } from '@deepseek-ai/dsh-paths' // Empty type import carries the httpServer Context merge for the port read below. import type {} from '@deepseek-ai/dsh-host-webserver' @@ -110,9 +110,9 @@ export interface AppCLIEntryOptions { */ overlayPath: string /** - * Optional extra overlay applied after {@link overlayPath} and before this - * entry's own profile/flag patches — the `--config` escape for demos and - * tests that need a row this surface does not ship. + * Optional explicit overlay applied after {@link overlayPath} and before + * this entry's own profile/flag patches. When absent, the personal + * `$DSH_HOME/config.yaml` overlay is applied instead. */ extraOverlayPath?: string /** Whether to append the HMR row (the whole prod/dev difference; web surface only). */ @@ -243,7 +243,7 @@ export class AppCLIEntry { const patches = [ ...loadOverlayPatches('dsh', this.options.overlayPath), ...this.options.extraOverlayPath === undefined - ? [] + ? loadPersonalPatches('dsh') ?? [] : loadOverlayPatches('dsh', this.options.extraOverlayPath), ...this.patches, ] diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index d6e0c159d4..af97d82af3 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -27,7 +27,8 @@ const LOOPBACK_HOST = '127.0.0.1' * @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback. * @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone. * @param config - an overlay of loader patches applied over the shipped web - * composition, or `undefined` for none; already parsed from `--config`. + * composition instead of `$DSH_HOME/config.yaml`, or `undefined` to use the + * personal overlay; already parsed from `--config`. */ export async function runWeb( host: string | undefined, From c19facf9db9f19e98fb892ee693c3215be93965e Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 23:39:09 +0800 Subject: [PATCH 48/77] refactor(cli): share app boot driver --- apps/cli/src/app-cli-entry.ts | 30 +++++++----------------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 9f258257b7..00f3c373ad 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -11,13 +11,11 @@ import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { networkInterfaces } from 'node:os' import { join, resolve } from 'node:path' -import { pathToFileURL } from 'node:url' import { Context } from 'cordis' import type { FiberState } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include, { type PatchOptions } from '@cordisjs/plugin-include' +import type { PatchOptions } from '@cordisjs/plugin-include' import yaml from 'js-yaml' -import { assertEntriesLoaded, installFailLoud, loadEnv, loadOverlayPatches, loadPersonalPatches } from '@deepseek-ai/dsh-app-boot' +import { boot, installFailLoud, loadEnv, loadOverlayPatches, loadPersonalPatches } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome, resolveSessionsRoot } from '@deepseek-ai/dsh-paths' // Empty type import carries the httpServer Context merge for the port read below. import type {} from '@deepseek-ai/dsh-host-webserver' @@ -230,12 +228,8 @@ export class AppCLIEntry { }) } - /** Loader include boot; the dev HMR row mounts before await so the fail-loud triple covers it. */ + /** Shared Loader boot; the dev HMR row mounts before await so the fail-loud sweep covers it. */ private async bootTree(): Promise { - const ctx = new Context() - ctx.baseUrl = pathToFileURL(join(resolve(this.options.configPath), '..')).href + '/' - await ctx.plugin(Loader) - ctx.loader.builtins.include = Include // One include of the shared base with every overlay as a sibling patch // list: patches never cross an include boundary, so nesting them would // silently stop reaching base rows. The surface overlay applies first, then @@ -247,28 +241,18 @@ export class AppCLIEntry { : loadOverlayPatches('dsh', this.options.extraOverlayPath), ...this.patches, ] - await ctx.loader.create({ - name: 'cordis:include', - config: { - path: pathToFileURL(resolve(this.options.configPath)).href, - ...patches.length > 0 ? { patches } : {}, - }, + this.ctx = await boot('dsh', resolve(this.options.configPath), patches, async (ctx) => { + if (this.options.dev) await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' }) }) - if (this.options.dev) { - await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' }) - } - this.ctx = ctx - await ctx.loader.await() } /** - * Fail-loud triple: assertEntriesLoaded catches import failures, - * installFailLoud catches late apply rejections, and the all-ACTIVE sweep + * Shared boot catches import failures, installFailLoud catches late apply + * rejections, and the all-ACTIVE sweep * below catches PENDING fibers (cordis inject waiting has no timeout). */ private assertBoot(): void { installFailLoud('dsh') - assertEntriesLoaded(this.ctx, 'dsh') const failures: string[] = [] for (const entry of this.ctx.loader.entries()) { if (entry.fiber === undefined || entry.disabled) continue From 74f75e90054ea79b9dcae271ba158685b80bf935 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 23:47:46 +0800 Subject: [PATCH 49/77] test(cli): align autocomplete test deadline --- apps/cli/tests/tui-keyless-smoke.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 6eb4182114..7285810267 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -310,7 +310,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('File · terminal-special-case.t') expect(output).toContain('@src/terminal-special-case.ts') expect(output).toContain('\u001B[?2004l') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, process.env.DSH_EXAMPLE_MODE === 'lib' ? 75_000 : LOADER_SMOKE_TEST_TIMEOUT_MS) it('boots the Code Mode overlay tree, renders its banner, and exits cleanly', async () => { // The overlay's only keyless composition proof: the include+patch tree, From 2adb9a541653e9305c844c8b1b370150d992be1b Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 23:59:52 +0800 Subject: [PATCH 50/77] fix(tui): avoid blocking reference autocomplete --- packages/ui/tui/src/chat/autocomplete.ts | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/ui/tui/src/chat/autocomplete.ts b/packages/ui/tui/src/chat/autocomplete.ts index d63ed3614d..cff83ac980 100644 --- a/packages/ui/tui/src/chat/autocomplete.ts +++ b/packages/ui/tui/src/chat/autocomplete.ts @@ -33,25 +33,23 @@ export class ReferenceAutocompleteProvider implements AutocompleteProvider { cursorCol: number, options: { signal: AbortSignal; force?: boolean }, ): Promise { - const basePromise = this.base.getSuggestions(lines, cursorLine, cursorCol, options) const currentLine = lines[cursorLine] /* v8 ignore next -- Editor always supplies its current state line. */ - if (currentLine === undefined) return basePromise + if (currentLine === undefined) return this.base.getSuggestions(lines, cursorLine, cursorCol, options) const token = activeAtToken(currentLine, cursorCol) if (token === undefined) { this.files.invalidate() - return basePromise + return this.base.getSuggestions(lines, cursorLine, cursorCol, options) } const filePromise = this.files.list(token.query, options.signal).catch(() => []) const sessionPromise = this.sessions === undefined || token.quoted ? Promise.resolve([]) : this.sessions.listCandidates(this.agent, token.query, undefined, options.signal).catch(() => []) - const [base, fileCandidates, sessionCandidates] = await Promise.all([ - basePromise, - filePromise, - sessionPromise, - ]) - if (options.signal.aborted) return base + // The command provider has no candidates for an active `@` token. Do not + // await it here: upstream may defer its empty result while the editor waits + // for this provider, which would suppress otherwise ready references. + const [fileCandidates, sessionCandidates] = await Promise.all([filePromise, sessionPromise]) + if (options.signal.aborted) return null const fileItems: AutocompleteItem[] = fileCandidates.flatMap((candidate) => { const value = formatFileMention(candidate, token.quoted) if (value === undefined) return [] @@ -75,8 +73,8 @@ export class ReferenceAutocompleteProvider implements AutocompleteProvider { } }) const items = [...fileItems, ...sessionItems] - if (items.length === 0) return base - return { items: [...items, ...(base?.items ?? [])], prefix: token.prefix } + if (items.length === 0) return null + return { items, prefix: token.prefix } } applyCompletion( From 131470faa032307e78c2c24a7d149a2f423e8991 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 00:07:21 +0800 Subject: [PATCH 51/77] Revert "fix(tui): avoid blocking reference autocomplete" This reverts commit f565ce44db708f1186354526a170513d48b07a2c. --- packages/ui/tui/src/chat/autocomplete.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/ui/tui/src/chat/autocomplete.ts b/packages/ui/tui/src/chat/autocomplete.ts index cff83ac980..d63ed3614d 100644 --- a/packages/ui/tui/src/chat/autocomplete.ts +++ b/packages/ui/tui/src/chat/autocomplete.ts @@ -33,23 +33,25 @@ export class ReferenceAutocompleteProvider implements AutocompleteProvider { cursorCol: number, options: { signal: AbortSignal; force?: boolean }, ): Promise { + const basePromise = this.base.getSuggestions(lines, cursorLine, cursorCol, options) const currentLine = lines[cursorLine] /* v8 ignore next -- Editor always supplies its current state line. */ - if (currentLine === undefined) return this.base.getSuggestions(lines, cursorLine, cursorCol, options) + if (currentLine === undefined) return basePromise const token = activeAtToken(currentLine, cursorCol) if (token === undefined) { this.files.invalidate() - return this.base.getSuggestions(lines, cursorLine, cursorCol, options) + return basePromise } const filePromise = this.files.list(token.query, options.signal).catch(() => []) const sessionPromise = this.sessions === undefined || token.quoted ? Promise.resolve([]) : this.sessions.listCandidates(this.agent, token.query, undefined, options.signal).catch(() => []) - // The command provider has no candidates for an active `@` token. Do not - // await it here: upstream may defer its empty result while the editor waits - // for this provider, which would suppress otherwise ready references. - const [fileCandidates, sessionCandidates] = await Promise.all([filePromise, sessionPromise]) - if (options.signal.aborted) return null + const [base, fileCandidates, sessionCandidates] = await Promise.all([ + basePromise, + filePromise, + sessionPromise, + ]) + if (options.signal.aborted) return base const fileItems: AutocompleteItem[] = fileCandidates.flatMap((candidate) => { const value = formatFileMention(candidate, token.quoted) if (value === undefined) return [] @@ -73,8 +75,8 @@ export class ReferenceAutocompleteProvider implements AutocompleteProvider { } }) const items = [...fileItems, ...sessionItems] - if (items.length === 0) return null - return { items, prefix: token.prefix } + if (items.length === 0) return base + return { items: [...items, ...(base?.items ?? [])], prefix: token.prefix } } applyCompletion( From 7dfead779903c920e9960390d5fa3de347d50abf Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 00:09:03 +0800 Subject: [PATCH 52/77] test(cli): keep file completion in source plane --- apps/cli/tests/tui-keyless-smoke.e2e.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 4b10b818cb..4da6203477 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -290,12 +290,13 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('fuzzy-completes an @file path without reading or submitting the file', async () => { + it.skipIf(process.env.DSH_EXAMPLE_MODE === 'lib')('fuzzy-completes an @file path without reading or submitting the file', async () => { const output = await smoke({ label: 'dsh file autocomplete', tempDirPrefix: 'dsh-tui-file-autocomplete-', - // The shipped composition: no welcome, so the banner's session-id detail - // line marks a settled boot. Completion never calls the model. + // Source-plane PTY coverage complements the deterministic package-level + // autocomplete tests. Artifact CI omits this timing-sensitive terminal + // rendering assertion; built boot is covered by the neighboring cases. configArgs: [], prepare: seedWorkspace({ workspace: { @@ -312,7 +313,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('File · terminal-special-case.t') expect(output).toContain('@src/terminal-special-case.ts') expect(output).toContain('\u001B[?2004l') - }, process.env.DSH_EXAMPLE_MODE === 'lib' ? 75_000 : LOADER_SMOKE_TEST_TIMEOUT_MS) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('boots the Code Mode overlay tree, renders its banner, and exits cleanly', async () => { // The overlay's only keyless composition proof: the include+patch tree, From 3685740179f11b51ddab8f4b87a6048ffa66f915 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 00:23:58 +0800 Subject: [PATCH 53/77] test(cli): move skill policy snapshot fixture --- .../skill-invocation-policy/session.jsonl | 5 + .../terminal.expected.txt | 157 ++++++++++++++++++ .../.agents/skills/model-only-skill/SKILL.md | 7 + .../.agents/skills/user-only-skill/SKILL.md | 7 + 4 files changed, 176 insertions(+) create mode 100644 apps/cli/tests/snapshots/skill-invocation-policy/session.jsonl create mode 100644 apps/cli/tests/snapshots/skill-invocation-policy/terminal.expected.txt create mode 100644 apps/cli/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/model-only-skill/SKILL.md create mode 100644 apps/cli/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/user-only-skill/SKILL.md diff --git a/apps/cli/tests/snapshots/skill-invocation-policy/session.jsonl b/apps/cli/tests/snapshots/skill-invocation-policy/session.jsonl new file mode 100644 index 0000000000..e7d6e3aeec --- /dev/null +++ b/apps/cli/tests/snapshots/skill-invocation-policy/session.jsonl @@ -0,0 +1,5 @@ +{"type":"session","version":0,"id":"31f63cc0-0198-4ab2-bfde-79a4eb4f1867","createdAt":1783352180000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"assistant/chunk","seq":0,"time":1783352180001,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":1,"time":1783352180002,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"USER-ONLY SKILL LOADED"}}} +{"type":"assistant/chunk","seq":2,"time":1783352180003,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"USER-ONLY SKILL LOADED"}}}} +{"type":"assistant/chunk","seq":3,"time":1783352180004,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} diff --git a/apps/cli/tests/snapshots/skill-invocation-policy/terminal.expected.txt b/apps/cli/tests/snapshots/skill-invocation-policy/terminal.expected.txt new file mode 100644 index 0000000000..2d6068d48b --- /dev/null +++ b/apps/cli/tests/snapshots/skill-invocation-policy/terminal.expected.txt @@ -0,0 +1,157 @@ +=== skill autocomplete === +terminal 100x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH TUI snapshot" +cursor hidden column=13 viewportRow=5 bufferRow=5 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Recorded replay: skill-invocation-policy" + style 1-40 dim +2| " main-session" + style 1-12 dim +3| +4| "/workspace/project deepseek-v4-flash ↑0 ↓0 0% context" + style 0-51 fg=bright-magenta bold + style 54-70 dim + style 73-77 dim + style 80-89 dim +5| " dsh > /skill " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 13-13 inverse +6| " → skill:user-only-skill (project) — User-only assembled snapshot skill. " + style 7-78 fg=bright-magenta +7-35| + + +=== loaded exact invocation === +terminal 100x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title " Reference — DSH TUI snapshot" +cursor hidden column=7 viewportRow=30 bufferRow=30 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Reference" + style 1-40 dim +2| " main-session" + style 1-12 dim +3| +4| "You " + style 0-2 fg=bright-magenta bold underline +5| " " +6| "References in this skill are relative to " +7| "/workspace/project/.agents/skills/user-only-skill. " +8| " " +9| "USER-ONLY BODY " +10| " " +11| +12| "Context · dsh-tool-skill" + style 0-23 dim +13| "A skill is a reusable set of task-specific instructions. The following skills are available in this " + style 0-99 dim +14| "session: " + style 0-7 dim +15| " " +16| " " + style 0-17 dim +17| "- `model-only-skill`: Model-only assembled snapshot skill. " + style 0-57 dim +18| " " + style 0-18 dim +19| " " +20| "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool " + style 0-99 dim +21| "with the exact skill name before taking task actions. Load all applicable skills, then follow their " + style 0-99 dim +22| "full instructions. This catalog contains summaries only; do not infer or follow a skill's " + style 0-99 dim +23| "instructions until it has been loaded. " + style 0-37 dim +24| +25| "Assistant " + style 0-8 fg=bright-magenta bold underline +26| "USER-ONLY SKILL LOADED " +27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +28| +29| "/workspace/project deepseek-v4-flash ↑0 ↓0 3% context" + style 0-51 fg=bright-magenta bold + style 54-70 dim + style 73-77 dim + style 80-89 dim +30| " dsh ◍ " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse +31-35| + + +=== denied exact invocation === +terminal 100x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title " Reference — DSH TUI snapshot" +cursor hidden column=7 viewportRow=32 bufferRow=32 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Reference" + style 1-40 dim +2| " main-session" + style 1-12 dim +3| +4| "You " + style 0-2 fg=bright-magenta bold underline +5| " " +6| "References in this skill are relative to " +7| "/workspace/project/.agents/skills/user-only-skill. " +8| " " +9| "USER-ONLY BODY " +10| " " +11| +12| "Context · dsh-tool-skill" + style 0-23 dim +13| "A skill is a reusable set of task-specific instructions. The following skills are available in this " + style 0-99 dim +14| "session: " + style 0-7 dim +15| " " +16| " " + style 0-17 dim +17| "- `model-only-skill`: Model-only assembled snapshot skill. " + style 0-57 dim +18| " " + style 0-18 dim +19| " " +20| "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool " + style 0-99 dim +21| "with the exact skill name before taking task actions. Load all applicable skills, then follow their " + style 0-99 dim +22| "full instructions. This catalog contains summaries only; do not infer or follow a skill's " + style 0-99 dim +23| "instructions until it has been loaded. " + style 0-37 dim +24| +25| "Assistant " + style 0-8 fg=bright-magenta bold underline +26| "USER-ONLY SKILL LOADED " +27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +28| +29| "Skill \"model-only-skill\" is not available for user invocation. " + style 0-61 fg=yellow +30| +31| "/workspace/project deepseek-v4-flash ↑0 ↓0 3% context" + style 0-51 fg=bright-magenta bold + style 54-70 dim + style 73-77 dim + style 80-89 dim +32| " dsh ◍ " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse +33-35| diff --git a/apps/cli/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/model-only-skill/SKILL.md b/apps/cli/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/model-only-skill/SKILL.md new file mode 100644 index 0000000000..3608a840f5 --- /dev/null +++ b/apps/cli/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/model-only-skill/SKILL.md @@ -0,0 +1,7 @@ +--- +name: model-only-skill +description: Model-only assembled snapshot skill. +user-invocable: false +--- + +MODEL-ONLY BODY MUST NOT LOAD diff --git a/apps/cli/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/user-only-skill/SKILL.md b/apps/cli/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/user-only-skill/SKILL.md new file mode 100644 index 0000000000..fa231cd99d --- /dev/null +++ b/apps/cli/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/user-only-skill/SKILL.md @@ -0,0 +1,7 @@ +--- +name: user-only-skill +description: User-only assembled snapshot skill. +disable-model-invocation: true +--- + +USER-ONLY BODY From c3b253db2829424154a01907a000887b448938eb Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 10:02:36 +0800 Subject: [PATCH 54/77] docs: drop unrelated README changes --- packages/guard/README.md | 4 ++-- packages/host/README.md | 2 +- packages/host/apiproxy/README.md | 2 +- packages/support/acp-snapshot/README.md | 2 +- packages/util/paths/README.md | 4 ---- 5 files changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/guard/README.md b/packages/guard/README.md index 59ab2fcea9..b7375fd2bb 100644 --- a/packages/guard/README.md +++ b/packages/guard/README.md @@ -2,10 +2,10 @@ English | [中文](README.zh.md) -Behavioral guard plugins that watch the agent loop and correct it — some by nudging the model back on course, some by refusing an operation outright. All are **product** packages: there is no interface/implementation seam here, because a guard is a self-contained consumer of existing core seams (`tools/pre-execute`, `tools/post-execute`, `agent/prompt-submit`, `agent/status`), not a swappable capability. +Behavioral guard plugins that watch the agent loop for unproductive patterns and nudge the model back on course. A single **product** package — there is no interface/implementation seam here, because a guard is a self-contained consumer of existing core seams (`tools/post-execute`, `agent/prompt-submit`, `agent/status`), not a swappable capability. | Package | Role | ctx key | |---|---|---| | `repeat-tool-guard/` | Advisory reminders when an agent loops on identical tool calls | (listens on `ctx.tools`' waterfalls) | -An advisory guard's reminders travel as `additionalContexts` on the `tools/post-execute` decision; the agent loop appends them as logged plugin-sourced `user/message` events after the step's tool results (see [the tools package](../core/tools)), so everything such a guard says to the model is reconstructable from the session log. An enforcing guard instead decides on `tools/pre-execute`, where a `deny` becomes the call's error result and the operation never dispatches. +Reminders travel as `additionalContexts` on the `tools/post-execute` decision; the agent loop appends them as logged plugin-sourced `user/message` events after the step's tool results (see [the tools package](../core/tools)), so everything a guard says to the model is reconstructable from the session log. diff --git a/packages/host/README.md b/packages/host/README.md index ec7eda7f44..d44770f70b 100644 --- a/packages/host/README.md +++ b/packages/host/README.md @@ -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/base.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/cordis.yml) serving [`apps/web`](../../apps/web/). All **product** packages. | Package | Role | ctx key | |---|---|---| diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 863f2ed58a..1f0daedc54 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -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/base.cordis.yml`](../../../apps/cli/base.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/cordis.yml`](../../../apps/cli/cordis.yml). ## Contract layer (`/api`) diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index a119656ce0..43666d2d11 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -51,7 +51,7 @@ defineAcpSnapshotSuite({ }) ``` -A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Default generated workspaces are stored in session fixtures as `{{cwd}}` so platform temp roots and random basenames do not affect recordings; `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test, keeps that explicit path in the fixture, and remains parent-owned while the harness removes only the generated child. `prepareCwd` runs after the `workspace/` fixture is copied and before the child boots, for world state a committed fixture cannot carry: git never tracks an entry named `.git`, and `.gitignore` excludes every `worktrees/` directory, so a repository-shaped scenario commits the file bodies under representable names and the hook assembles the real layout from them. A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes. +A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Default generated workspaces are stored in session fixtures as `{{cwd}}` so platform temp roots and random basenames do not affect recordings; `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test, keeps that explicit path in the fixture, and remains parent-owned while the harness removes only the generated child. A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes. Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario whose driven behavior needs POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere. diff --git a/packages/util/paths/README.md b/packages/util/paths/README.md index 837ee0cdf7..b28e684f31 100644 --- a/packages/util/paths/README.md +++ b/packages/util/paths/README.md @@ -16,10 +16,6 @@ Shared filesystem path helpers for DeepSeek Harness user data. `expandHomePath()` expands `~`, `~/...`, and Windows-style `~\...` prefixes against the operating-system home directory. It leaves non-tilde paths and `~user/...` untouched. -## Session store - -`resolveSessionsRoot()` resolves the shared session-store root under the Harness home, by the same precedence as `resolveDshHome()`. `SESSIONS_DIR_NAME` owns its directory name: `sessions`. Every surface that persists sessions resolves this one directory, so history is shared across working directories instead of scattered per project; a persistence backend may still partition inside it. Two surfaces resolving different roots would silently split one user's history into disjoint stores, which is why the location is owned here rather than joined per caller. - This package is intentionally small and harness-dep-free so product packages can share user-data path conventions without depending on one another. ## Known Limitations and Deferred Work From 7e36758cd6489810983ae17d3dec995e0c4d968e Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 10:03:49 +0800 Subject: [PATCH 55/77] docs: remove code mode example --- examples/code-mode/README.i18n.yaml | 6 ----- examples/code-mode/README.md | 35 ---------------------------- examples/code-mode/README.zh.md | 35 ---------------------------- examples/code-mode/cordis.yml | 36 ----------------------------- examples/code-mode/package.json | 7 ------ 5 files changed, 119 deletions(-) delete mode 100644 examples/code-mode/README.i18n.yaml delete mode 100644 examples/code-mode/README.md delete mode 100644 examples/code-mode/README.zh.md delete mode 100644 examples/code-mode/cordis.yml delete mode 100644 examples/code-mode/package.json diff --git a/examples/code-mode/README.i18n.yaml b/examples/code-mode/README.i18n.yaml deleted file mode 100644 index 03d2c40a85..0000000000 --- a/examples/code-mode/README.i18n.yaml +++ /dev/null @@ -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 examples/code-mode/README.md -README.md: 1557483ee98fab60aa63f8c5ec40f7e592c275ba -README.zh.md: c20a9a7dde80eb0e03f17b62b5dc21189ef65f06 diff --git a/examples/code-mode/README.md b/examples/code-mode/README.md deleted file mode 100644 index 1557483ee9..0000000000 --- a/examples/code-mode/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# code-mode - -English | [中文](README.zh.md) - -Code Mode over the shipped TUI: the model stops calling one tool per turn and instead writes TypeScript programs for a single `run_code` tool, executed in a worker thread. - -This leaf is an **overlay**, not a tree. `dsh --config` includes the shared [`apps/cli/base.cordis.yml`](../../apps/cli/base.cordis.yml), applies the [`tui.cordis.yml`](../../apps/cli/tui.cordis.yml) surface overlay, then applies this file — all as sibling patch lists at one include level. - -## Run it - -```sh -# repo root .env (gitignored) or exported env: -# DEEPSEEK_API_KEY=sk-… -# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:code-mode -``` - -`pnpm run demo:code-mode acp` boots the same idea over the ACP transport from [`examples/acp-agent/code-mode.cordis.yml`](../acp-agent/code-mode.cordis.yml). - -## What the overlay changes - -| Row | Change | -| --- | --- | -| `tools` | `mode: code` — the wire registry collapses to `run_code` plus its generated TypeScript SDK prompt section | -| `system-prompt` | a persona telling the model to batch tool work into one program | -| `tui` | a Code Mode welcome line | -| `code-runtime` | inserted: `@deepseek-ai/dsh-code-runtime-worker`, the worker thread `run_code` executes in | - -Everything else — the model backend, executors, filesystem tools, persistence, delegation, and the front door — comes from the base and the TUI overlay. - -A patch replaces a row's whole `config` rather than merging into it, so each row above restates every key it owns. A patch whose `id` matches no row is skipped with a Loader warning, so renaming a row in the base or the surface overlay requires updating this file. - -## Model Experience - -The model sees one tool instead of the full native registry. Its request carries the `run_code` schema and a generated TypeScript SDK section describing the callable surface, which costs more prompt tokens up front but replaces many single-call turns with one program — fewer round trips and fewer intermediate tool results in the transcript. Because the tool catalog is part of the cached request prefix, switching modes invalidates the KV cache for the session. diff --git a/examples/code-mode/README.zh.md b/examples/code-mode/README.zh.md deleted file mode 100644 index c20a9a7dde..0000000000 --- a/examples/code-mode/README.zh.md +++ /dev/null @@ -1,35 +0,0 @@ -# code-mode - -[English](README.md) | 中文 - -在交付的 TUI 之上启用 Code Mode:模型不再每轮调用一个工具,而是为单一的 `run_code` 工具编写 TypeScript 程序,并在 worker 线程中执行。 - -本叶节点是一个 **overlay**,而不是配置树。`dsh --config` 会 include 共享的 [`apps/cli/base.cordis.yml`](../../apps/cli/base.cordis.yml),应用 [`tui.cordis.yml`](../../apps/cli/tui.cordis.yml) surface overlay,再应用本文件——三者都是同一 include 层级上的平级 patch 列表。 - -## 运行 - -```sh -# repo root .env (gitignored) or exported env: -# DEEPSEEK_API_KEY=sk-… -# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:code-mode -``` - -`pnpm run demo:code-mode acp` 通过 ACP 传输启动同样的思路,配置位于 [`examples/acp-agent/code-mode.cordis.yml`](../acp-agent/code-mode.cordis.yml)。 - -## 该 overlay 改动了什么 - -| 配置项 | 改动 | -| --- | --- | -| `tools` | `mode: code`——线上注册表收敛为 `run_code`,外加其生成的 TypeScript SDK 提示词片段 | -| `system-prompt` | 指示模型把相关工具调用批量写进一个程序的 persona | -| `tui` | Code Mode 的欢迎行 | -| `code-runtime` | 新插入:`@deepseek-ai/dsh-code-runtime-worker`,即 `run_code` 的执行 worker 线程 | - -其余部分——模型后端、执行器、文件系统工具、持久化、委派与前端入口——全部来自 base 与 TUI overlay。 - -配置 patch 会整体替换该配置项的 `config`,而非与其合并,因此上表每一行都必须重述自己拥有的全部键。若某个 patch 的 `id` 不再匹配任何配置项,Loader 只会告警并跳过它,所以在 base 或 surface overlay 中重命名配置项时必须同步更新本文件。 - -## Model Experience(模型体验) - -模型看到的是一个工具,而不是完整的原生注册表。其请求携带 `run_code` 的 schema 以及一段生成的 TypeScript SDK 说明,用于描述可调用面。这会在前置阶段消耗更多提示词 token,但把多个单次调用的轮次压缩为一个程序——往返更少,transcript 中的中间工具结果也更少。由于工具目录属于被缓存的请求前缀,切换模式会使该会话的 KV 缓存失效。 diff --git a/examples/code-mode/cordis.yml b/examples/code-mode/cordis.yml deleted file mode 100644 index 551b318647..0000000000 --- a/examples/code-mode/cordis.yml +++ /dev/null @@ -1,36 +0,0 @@ -# Code Mode over the shipped TUI: the model stops calling one tool per turn and -# instead writes TypeScript programs for a single `run_code` tool, executed in a -# worker thread. -# -# This file is a `--config` OVERLAY, not a tree: `dsh --config -# examples/code-mode/cordis.yml` includes the shared base, applies the TUI -# overlay, then applies these patches, all at one include level. Patches never -# cross an include boundary, so an overlay must stay a patch list rather than a -# file that includes another config. -# -# A patch replaces the targeted row's whole `config`, so each row restates every -# key it owns. -- id: tools - config: - mode: code - -- id: system-prompt - config: - persona: | - You are a coding agent powered by the {{model}} model. - - You work by writing TypeScript programs for run_code: batch related - tool work into one program, loop and branch where it helps, and print - or return ONLY the findings that matter. - -- id: tui - config: - sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main' - welcome: 'TUI Code Mode ready. Give it a multi-tool task.' - showReasoning: true - maxToolOutputLines: 6 - -# The worker thread `run_code` executes in; the base mounts no code runtime. -- insert: - - id: code-runtime - name: '@deepseek-ai/dsh-code-runtime-worker' diff --git a/examples/code-mode/package.json b/examples/code-mode/package.json deleted file mode 100644 index 9a5ec92480..0000000000 --- a/examples/code-mode/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "code-mode-example", - "private": true, - "version": "0.0.1", - "type": "module", - "description": "Runnable overlay: the shipped TUI composition with the model-facing registry reduced to the run_code transport" -} From 9e9653202c4694bcbe8c20b39825583f49d42859 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 10:04:23 +0800 Subject: [PATCH 56/77] docs: restore unrelated translated READMEs --- packages/guard/README.i18n.yaml | 4 ++-- packages/guard/README.zh.md | 6 +++--- packages/host/README.i18n.yaml | 4 ++-- packages/host/README.zh.md | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 ++-- packages/host/apiproxy/README.zh.md | 2 +- packages/support/acp-snapshot/README.i18n.yaml | 4 ++-- packages/support/acp-snapshot/README.zh.md | 2 +- packages/util/paths/README.i18n.yaml | 4 ++-- packages/util/paths/README.zh.md | 8 ++------ 10 files changed, 18 insertions(+), 22 deletions(-) diff --git a/packages/guard/README.i18n.yaml b/packages/guard/README.i18n.yaml index 0f6859f941..c323a9b295 100644 --- a/packages/guard/README.i18n.yaml +++ b/packages/guard/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/guard/README.md -README.md: 59ab2fcea91bbbb9f6628523f3c6f13497d6f742 -README.zh.md: caec5618f9ddc27825ad68cd4145f6197c7b0517 +README.md: b7375fd2bb12ae0cec94b13e6a1012c6f143bdad +README.zh.md: bba5c144d0663266e4327e388b9915cde176ce08 diff --git a/packages/guard/README.zh.md b/packages/guard/README.zh.md index caec5618f9..bba5c144d0 100644 --- a/packages/guard/README.zh.md +++ b/packages/guard/README.zh.md @@ -2,10 +2,10 @@ [English](README.md) | 中文 -这组行为 guard 插件会监视 agent(智能体)循环并加以纠正:一部分提醒模型调整方向,另一部分则直接拒绝某个操作。它们都是**产品**包,不设接口/实现 seam:guard 是现有核心 seam(`tools/pre-execute`、`tools/post-execute`、`agent/prompt-submit`、`agent/status`)的自包含消费方,并非可替换能力。 +这组行为 guard 插件会监视 agent loop(智能体循环)中的低效模式,并提醒模型调整方向。这里只有一个**产品**包(package),不设接口/实现 seam:guard 是现有核心 seam(`tools/post-execute`、`agent/prompt-submit`、`agent/status`)的自包含消费方,并非可替换能力。 | 包 | 职责 | ctx 键 | |---|---|---| -| `repeat-tool-guard/` | 当 agent 对完全相同的工具调用反复循环时给出提示 | (监听 `ctx.tools` 的 waterfall(瀑布式事件)) | +| `repeat-tool-guard/` | 当 agent 对完全相同的工具调用反复循环时给出提示 | (监听 `ctx.tools` 的 waterfall,即瀑布式事件) | -建议型 guard 的提示以 `additionalContexts` 形式附在 `tools/post-execute` 决策中传递;agent loop 会在该步骤的工具结果之后,将其追加为有日志记录、来源为插件的 `user/message` 事件(参见[工具包](../core/tools))。因此,此类 guard 告诉模型的所有内容都能从会话日志中重建。强制型 guard 则在 `tools/pre-execute` 上做出决策,其 `deny` 会成为该调用的错误结果,操作绝不会分派执行。 +提示以 `additionalContexts` 形式附在 `tools/post-execute` 决策中传递;agent loop 会在该步骤的工具结果之后,将其追加为有日志记录、来源为插件的 `user/message` 事件(参见[工具包](../core/tools))。因此,guard 告诉模型的所有内容都能从会话日志中重建。 diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml index 80bbcefa62..afc0e2a695 100644 --- a/packages/host/README.i18n.yaml +++ b/packages/host/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/README.md -README.md: ec7eda7f44ee7a2e8716f65aaa8d800b2c5abc5d -README.zh.md: 3c295d8a84a29cfd196f0089434cbd6d0341e08b +README.md: d44770f70be16c12f44b78155089e092a3e9bba0 +README.zh.md: 2b6878b08be6489dcd510a0a0e0f0e833c2a8014 diff --git a/packages/host/README.zh.md b/packages/host/README.zh.md index 3c295d8a84..2b6878b08b 100644 --- a/packages/host/README.zh.md +++ b/packages/host/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承载它的纯 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合后的应用是 [`apps/cli`](../../apps/cli/base.cordis.yml),它负责服务 [`apps/web`](../../apps/web/)。全部为**产品**包。 +dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承载它的纯 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合后的应用是 [`apps/cli`](../../apps/cli/cordis.yml),它负责服务 [`apps/web`](../../apps/web/)。全部为**产品**包。 | 包 | 角色 | ctx 键 | |---|---|---| diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index fbf250a09f..18202ed036 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 863f2ed58ad0490d5591ea77621088353e0fb39d -README.zh.md: 1f343084533f83abe3f1948e0785ce94f3838978 +README.md: 1f0daedc54888a1951bc83c474f83287aaf42307 +README.zh.md: abf5417cdbe93f1199c621ac101249986969da93 diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 1f34308453..abf5417cdb 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -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/base.cordis.yml`](../../../apps/cli/base.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/cordis.yml`](../../../apps/cli/cordis.yml)。 ## 契约层(`/api`) diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index be6b79d5c5..3c59b2ba93 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/support/acp-snapshot/README.md -README.md: a119656ce09863f3025f6f2ec7ffbedfb3bca4ca -README.zh.md: 736f34fec83d48b3d783b63e7bab0dec44fe1676 +README.md: 43666d2d117170f9d7f73737fb1704efc2a55f27 +README.zh.md: 608b2490f5de7dc7ec4ecd863f41df7f609451ac diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index 736f34fec8..608b2490f5 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -51,7 +51,7 @@ defineAcpSnapshotSuite({ }) ``` -启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。默认生成的 workspace 在会话 fixture 中存储为 `{{cwd}}`,使平台临时根目录和随机 basename 不影响录制结果;当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域,在 fixture 中保留该显式路径,并仍归父级所有,而 harness 只移除生成的子级。`prepareCwd` 在复制 `workspace/` fixture 后、子级启动前运行,用于提供已提交 fixture 无法承载的环境状态:Git 永远不会跟踪名为 `.git` 的条目,且 `.gitignore` 会排除所有 `worktrees/` 目录,因此仓库形态的场景会以可表示的名称提交文件内容,并由该钩子据此组装真实布局。每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource` 和 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。 +启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。默认生成的 workspace 在会话 fixture 中存储为 `{{cwd}}`,使平台临时根目录和随机 basename 不影响录制结果;当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域,在 fixture 中保留该显式路径,并仍归父级所有,而 harness 只移除生成的子级。每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource` 和 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。 每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。驱动行为需要 POSIX 进程语义的场景(例如取消实时 bash 调用会终止脱离进程组)声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件。 diff --git a/packages/util/paths/README.i18n.yaml b/packages/util/paths/README.i18n.yaml index f21ee7f88b..a57d18b601 100644 --- a/packages/util/paths/README.i18n.yaml +++ b/packages/util/paths/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/paths/README.md -README.md: 837ee0cdf7687fac58058e04ff5971002124038a -README.zh.md: d34eeb023a506d5c07f32638ac74698b3a913efa +README.md: b28e684f3183d739c8e229a9b341801dbf345d86 +README.zh.md: ab4e8123d19fd56749e3e7a0d59e8cd6bea0c3d0 diff --git a/packages/util/paths/README.zh.md b/packages/util/paths/README.zh.md index d34eeb023a..ab4e8123d1 100644 --- a/packages/util/paths/README.zh.md +++ b/packages/util/paths/README.zh.md @@ -16,13 +16,9 @@ DeepSeek Harness 用户数据的共享文件系统路径辅助工具。 `expandHomePath()` 使用操作系统主目录展开 `~`、`~/...` 和 Windows 风格的 `~\...` 前缀。它会保留非波浪号路径和 `~user/...` 原样不变。 -## 会话存储 +该包(package)刻意保持规模小且不依赖 harness,以便产品包共享用户数据路径约定,而不必彼此依赖。 -`resolveSessionsRoot()` 按与 `resolveDshHome()` 相同的优先级,解析 Harness 主目录下的共享会话存储根目录。`SESSIONS_DIR_NAME` 定义其目录名:`sessions`。每个持久化会话的界面都解析这同一个目录,因此历史记录在各工作目录之间共享,而不是按项目分散;持久化后端仍可在其内部分区。若两个界面解析出不同的根目录,会静默地把同一用户的历史记录拆成互不相交的存储,这正是该位置由此处拥有、而非由各调用方自行拼接的原因。 - -该包刻意保持规模小且不依赖 harness,以便产品包共享用户数据路径约定,而不必彼此依赖。 - -## 已知限制与待完成工作 +## 已知限制与暂缓事项 - **展开范围刻意保持狭窄**:只有单独的 `~`、`~/...` 和 `~\...` 使用当前操作系统主目录;`~alice/...` 等指定用户的形式、环境变量和 shell 表达式保持不变。 - **辅助工具不会操作文件系统**:调用方仍负责目录创建、存在性检查、权限,以及对结果路径应用信任策略。 From 96b874c98f8dc9fbbbae0c34ff6e56fa1c386a03 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 10:12:38 +0800 Subject: [PATCH 57/77] test(web): follow relocated CLI configs --- apps/web/tests/scaffold.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index b942040f0b..1c030b4097 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -61,8 +61,8 @@ export function webSnapshotMode(): WebSnapshotMode { } /** The shipped composition under test: apps/cli's shared base and web overlay. */ -const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/base.cordis.yml') -const WEB_OVERLAY_PATH = join(REPO_ROOT, 'apps/cli/web.cordis.yml') +const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/config/base.cordis.yml') +const WEB_OVERLAY_PATH = join(REPO_ROOT, 'apps/cli/config/web.cordis.yml') // Replay publishes the provider catalog the gateway routes to (providers // mode, never catch-all: with llm-deepseek disabled no adapter exists, so a From 545878c6a7516534e6d35c36b63f2a415c4f0d21 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 10:15:57 +0800 Subject: [PATCH 58/77] docs: restore unrelated todo READMEs --- packages/todo/README.i18n.yaml | 4 ++-- packages/todo/README.md | 2 +- packages/todo/README.zh.md | 6 ++--- packages/todo/tool-todo/README.i18n.yaml | 4 ++-- packages/todo/tool-todo/README.md | 2 +- packages/todo/tool-todo/README.zh.md | 30 ++++++++++++------------ 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/packages/todo/README.i18n.yaml b/packages/todo/README.i18n.yaml index 12b2ce442d..a7b1f6f529 100644 --- a/packages/todo/README.i18n.yaml +++ b/packages/todo/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/todo/README.md -README.md: 495851a13f70bc8e3cb2dc99da47ab305ca205fa -README.zh.md: abdac592a312cb36d145e7d16e01821561090dcb +README.md: 1e5ae9a1583b9e9d3913fcd1dca7ef11a5f391fe +README.zh.md: a77f788a41353ea547059864dc7cf73ac5025219 diff --git a/packages/todo/README.md b/packages/todo/README.md index 495851a13f..1e5ae9a158 100644 --- a/packages/todo/README.md +++ b/packages/todo/README.md @@ -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 front door](../ui/tui) 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](../examples/tui-demo) and the host/client runtime render the durable list from session events. diff --git a/packages/todo/README.zh.md b/packages/todo/README.zh.md index abdac592a3..a77f788a41 100644 --- a/packages/todo/README.zh.md +++ b/packages/todo/README.zh.md @@ -1,11 +1,11 @@ -# todo/:todo/规划能力系列 +# todo/:todo/规划能力家族 [English](README.md) | 中文 -面向模型的 todo 工具。它是单一 **产品** 包(package):这里没有接口/实现 seam,因为该列表是由单一所有者管理的会话状态(每个 agent(智能体)会话拥有自己的列表),而非可替换能力。 +面向模型的 todo 工具。它是单一 **产品**包(package):这里没有接口/实现 seam,因为该列表是由单一所有者管理的会话状态(每个 agent(智能体)会话拥有自己的列表),而非可替换能力。 | 包 | 职责 | ctx 键 | |---|---|---| | `tool-todo/` | 面向模型的 `todo_write` 工具;将完整列表写入会话日志(`todo/write`) | (注册到 `ctx.tools`) | -列表存在于事件溯源会话日志中(`SessionEventMap['todo/write']`,由 [`dsh-session`](../core/session) 拥有);本包是追加快照的轻量消费方。[TUI 前端入口](../ui/tui)等 UI 以及宿主/客户端运行时会根据会话事件渲染该持久列表。 +列表存在于事件溯源会话日志中(`SessionEventMap['todo/write']`,由 [`dsh-session`](../core/session) 拥有);本包是追加快照的轻量消费方。[TUI 应用](../examples/tui-demo)等 UI 以及宿主/客户端运行时会根据会话事件渲染该持久化列表。 diff --git a/packages/todo/tool-todo/README.i18n.yaml b/packages/todo/tool-todo/README.i18n.yaml index eb152a048e..603d2cfef0 100644 --- a/packages/todo/tool-todo/README.i18n.yaml +++ b/packages/todo/tool-todo/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/todo/tool-todo/README.md -README.md: f86a0d7331cc822a6568eeb14b89a64abaa23378 -README.zh.md: 778c920198599546890c90d21badf2d7bb0f2551 +README.md: b05ef43e7137dcf5678b1f1ad6d8c00b8a43baef +README.zh.md: 88a9f5d69ff52dcedbd8f27a4ace5fde3e0dc5a2 diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index f86a0d7331..b05ef43e71 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -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 front door](../../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)). +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)). ## Session projection diff --git a/packages/todo/tool-todo/README.zh.md b/packages/todo/tool-todo/README.zh.md index 778c920198..88a9f5d69f 100644 --- a/packages/todo/tool-todo/README.zh.md +++ b/packages/todo/tool-todo/README.zh.md @@ -6,62 +6,62 @@ ## 功能 -注册一个工具 `todo_write(todos: [{ content, status }])` 到 `ctx.tools`。模型每次调用都会发送完整列表,不存在部分更新或单项编辑。每次调用都会向调用 agent 的会话日志追加 `todo/write` 事件(完整列表快照),具体调用 `agent.session.append('todo/write', { todos })`;当前列表是最新的该类事件(回放时后写者胜)。 +注册一个工具 `todo_write(todos: [{ content, status }])` 到 `ctx.tools`。模型每次调用都会发送完整列表,不存在部分更新或单项编辑。每次调用都会向调用 agent 的会话日志追加 `todo/write` 事件(完整列表快照),具体调用 `agent.session.append('todo/write', { todos })`;当前列表是最新的该类事件(回放时后写覆盖先写)。 `status` 是 `pending`、`in_progress` 或 `completed` 之一。 ## 单一所有者 -该列表属于调用工具的唯一 agent 会话。不存在 subagent/共享/swarm scope:非 agent 调用方(没有 `exec.agent`)无处写入列表,因此会被拒绝。这是有意设置的 scope 限制,详见 Agent Note。 +该列表属于调用工具的唯一 agent 会话。不存在 subagent/共享/swarm scope:非 agent 调用方(没有 `exec.agent`)无处写入列表,因此会被拒绝。这是有意设置的 scope 限制,详见 Agent Note(agent 决策记录)。 ## 验证 -除 schema 的类型/必填/枚举检查外,`execute` 还会拒绝空或重复的 `content`、同时存在多个 `in_progress` 任务的情况(连贯计划最多只有一个活跃任务),以及 `content`/`status` 之外的任何条目键——扩展条目形状(id、嵌套)会响亮失败而不是被静默压平,保证落日志的快照与模型自认为写入的内容一致。顺序与保持列表最新的纪律由模型根据工具描述负责。 +除 schema 的类型/必填/枚举检查外,`execute` 还会拒绝空或重复的 `content`、同时存在多个 `in_progress` 任务的情况(连贯计划最多只有一个活跃任务),以及 `content`/`status` 之外的任何条目键——扩展条目形状(id、嵌套)会明确报错而不是被静默压平,保证落日志的快照与模型自认为写入的内容一致。列表的顺序及及时更新由模型依照工具描述负责。 ## 渲染 -规范结果为 `{ 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))。 +规范结果为 `{ 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))。 ## 会话投影 -当组合挂载了 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md))时,本包在一个注入式子插件下注册 `todos` 投影单元:`init` = `null`(尚无写入)、`apply` = 从每个 `todo/write` 取整表,并在每个 `turn/start` 清为 `null`(站立计划;`turn/end` 保留刚完成的清单;其余事件都返回同一个状态引用)、`view` = 恒等、`stateVersion` = 2。key 在本包合并进 `SessionProjectionMap`(经接口包的 `/types` 出口);框架驱动该单元,载体在历史尾页与 `session/projection` 推送帧上供给该值。未装注册表的组合不受影响。生命周期理由见 [下一轮清空 todo 计划条](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md)。 +当组合挂载了 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md))时,本包在一个注入的子插件中注册 `todos` 投影单元:`init` = `null`(尚无写入)、`apply` = 从每个 `todo/write` 取整表,并在每个 `turn/start` 清为 `null`(当前有效计划;`turn/end` 保留刚完成的清单;其余事件都返回同一个状态引用)、`view` = 恒等、`stateVersion` = 2。该键在本包中合并进 `SessionProjectionMap`(经接口包的 `/types` 出口);框架驱动该单元,载体通过历史尾页与 `session/projection` 推送帧提供该值。未挂载注册表的组合不受影响。生命周期理由见 [在下一轮次清空 todo 计划](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md)。 ## 导出形状 -函数/命名空间插件:导出 `name`/`inject`/`apply`,不提供默认导出。意外的 `export default` 会通过 Loader 的 `unwrapExports` 折叠模块并丢弃 `inject`(参见 [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md))。 +函数/命名空间插件:导出 `name`/`inject`/`apply`,不提供默认导出。意外的 `export default` 会被 Loader 的 `unwrapExports` 折叠为默认导出,并导致 `inject` 丢失(参见 [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md))。 ## 模型体验 ### 工具 schema -#### 模型所见内容 +#### 模型看到的内容 模型会看到生成的 [`todo_write` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-todo)。 #### Token 影响 -工具可见的每个请求都有固定 schema 成本。 +工具可见的每个请求都有固定的 schema token 开销。 #### KV Cache 影响 -只要定义和可见性不变,前缀就保持稳定。插件生命周期或 scope 限制可能会使此 schema 之后的复用失效。 +只要定义和可见性不变,前缀就保持稳定。插件生命周期或 scope 限制可能会使从此 schema 起的缓存复用失效。 ### 工具调用历史与结果 -#### 模型所见内容 +#### 模型看到的内容 -每个 assistant 工具调用都会在参数中保留整个替换列表。成功时精确返回 `Updated todo list: pending, in progress, completed.`。稳定失败文本为 ``Error: invalid todo: `content` must be a non-empty string``、`Error: invalid todos: duplicate content ""`、`Error: invalid todos: at most one task may be in_progress, got ` 和 `Error: todo_write requires an owning agent session`。完整 `todo/write` 会话事件是 UI 与回放状态,而非第二条模型消息。 +每个 assistant 工具调用都会在参数中保留整个替换列表。成功时原样返回 `Updated todo list: pending, in progress, completed.`。稳定失败文本为 ``Error: invalid todo: `content` must be a non-empty string``、`Error: invalid todos: duplicate content ""`、`Error: invalid todos: at most one task may be in_progress, got ` 和 `Error: todo_write requires an owning agent session`。完整 `todo/write` 会话事件是 UI 与回放状态,而非第二条模型消息。 #### Token 影响 -Token 增长与模型每次提交的完整列表成比例,且这些调用参数会保留到压缩(compaction)。结果本身很小,且形状固定。 +token 用量会随模型每次提交的完整列表增长,且这些调用参数会保留到压缩(compaction)。结果本身很小,且形状固定。 #### KV Cache 影响 仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 -## 已知限制与延后工作 +## 已知限制与暂缓事项 -- **仅单一所有者 scope**:列表属于唯一调用 agent 会话;subagent/共享/swarm scope 是有意裁减(参见「单一所有者」一节),非 agent 调用方会被拒绝。 -- **项目形状有意保持最小**:`content` 加三态 `status`;整表替换不需要稳定 id、优先级或 active-form 字段。 +- **仅单一所有者 scope**:列表属于唯一调用 agent 会话;subagent/共享/swarm scope 是有意设置的限制(参见「单一所有者」一节),非 agent 调用方会被拒绝。 +- **条目形状有意保持最小**:`content` 加三态 `status`;整表替换不需要稳定 id、优先级或 active-form 字段。 - **整表替换是唯一操作**:没有部分更新,也没有回读工具;模型每次调用都必须重新发送完整列表。 From 5111a292b47a0c29d35ce080be5eb62e12359f49 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 10:17:51 +0800 Subject: [PATCH 59/77] refactor: drop unrelated ACP snapshot changes --- examples/acp-agent/composition.md | 2 +- packages/support/acp-snapshot/src/harness.ts | 8 -------- packages/support/acp-snapshot/src/suite.ts | 7 ------- .../tests/fixtures/suite/plain-turn/stdout.expected.jsonl | 2 +- packages/support/acp-snapshot/tests/suite.spec.ts | 3 +-- 5 files changed, 3 insertions(+), 19 deletions(-) diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index 6cd896ca31..b4a3236920 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -94,4 +94,4 @@ flowchart LR Source config: [`examples/acp-agent/cordis.yml`](cordis.yml). -Maintenance mode: hybrid: the leaf plugin list is parsed from its shipped config files; app package expansion is curated from package source. +Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index 56df34e80f..d11a4f1b9c 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -155,13 +155,6 @@ export interface RunOptions { * start from an empty workspace. */ workspaceDir?: string - /** - * Optional setup run in the generated cwd after {@link workspaceDir} is - * copied and before the child boots — for world state a committed fixture - * cannot express, such as a `.git` entry (git never tracks that name, so a - * repository-shaped fixture has to be materialized at run time). - */ - prepareCwd?: (cwd: string) => Promise /** * Parent directory for the generated session cwd. Defaults to * `os.tmpdir()`. A scenario that must distinguish its workspace from the @@ -228,7 +221,6 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise if (opts.workspaceDir !== undefined && existsSync(opts.workspaceDir)) { await cp(opts.workspaceDir, cwd, { recursive: true }) } - await opts.prepareCwd?.(cwd) const env: NodeJS.ProcessEnv = { ...opts.env, DSH_SNAPSHOT: opts.mode, diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index efc1165c45..d04a481993 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -130,12 +130,6 @@ export interface Scenario { * test and the scenario needs an independent project location. */ workspaceParent?: string - /** - * Setup run in the generated cwd after the `workspace/` fixture is copied and - * before the child boots, for world state a committed fixture cannot express - * — a `.git` entry, which git never tracks under that name. - */ - prepareCwd?: (cwd: string) => Promise /** * Whether Windows additionally compares stdout with native separators against * `stdout.expected.windows.jsonl`. The shared canonical stdout expected output is still @@ -987,7 +981,6 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { ...!RECORDING && childFixtureFiles.length > 0 ? { childFiles: childFixtureFiles.map(file => join(dir, file)) } : {}, ...existsSync(workspaceDir) ? { workspaceDir } : {}, ...scenario.workspaceParent !== undefined ? { workspaceParent: scenario.workspaceParent } : {}, - ...scenario.prepareCwd !== undefined ? { prepareCwd: scenario.prepareCwd } : {}, // A scenario booting an overlay tree passes its own live config; the // bin's replay swap derives the sibling `*cordis.snapshot.yml` from it. ...scenario.configPath !== undefined ? { configPath: scenario.configPath } : {}, diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/stdout.expected.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/stdout.expected.jsonl index bed4c47530..d0242ae39f 100644 --- a/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/stdout.expected.jsonl +++ b/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":false}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"thinking about it"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"workspace:prepared.marker,seed.txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"workspace:seed.txt"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index a8dec942ae..420dcef2cf 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -1,5 +1,5 @@ import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { rm, writeFile } from 'node:fs/promises' +import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -78,7 +78,6 @@ const REPLAY_SCENARIOS: Scenario[] = [ env: { DSH_PERMISSION_MODE: 'never' }, configPath: AGENT.configPath, workspaceParent: tmpdir(), - prepareCwd: async (cwd) => { await writeFile(join(cwd, 'prepared.marker'), 'prepared\n') }, }, { name: 'no-model', hasModelTurn: false, recorded: false, headerClass: 'main' }, { name: 'blocked-log', hasModelTurn: false, comparesLog: true, recorded: false, headerClass: 'main' }, From 83360c3dcaf4b16efd4ae8524bf4cb61d1cdc480 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 10:24:04 +0800 Subject: [PATCH 60/77] fix(cli): default session root in shipped config --- apps/cli/config/base.cordis.yml | 6 +----- apps/cli/src/app-cli-entry.ts | 7 +------ apps/cli/src/tui.ts | 16 +--------------- apps/cli/tests/sessions-root.spec.ts | 20 -------------------- packages/ui/tui/src/index.ts | 12 ------------ packages/util/paths/src/index.ts | 21 --------------------- packages/util/paths/tests/paths.spec.ts | 11 ----------- 7 files changed, 3 insertions(+), 90 deletions(-) delete mode 100644 apps/cli/tests/sessions-root.spec.ts diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index 9f69fcc627..a6f84d1c51 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -67,14 +67,10 @@ apiKey: !!js process.env.ANTHROPIC_API_KEY baseURL: !!js process.env.ANTHROPIC_BASE_URL -# The session store root is the launcher's policy, not a plugin's: `dsh` shares -# one store under the Harness home across every cwd, so `/resume` spans -# workspaces. Without a launcher the project-local fallback keeps -# an embedder's sessions beside its project. - id: session-persistence-jsonl name: '@deepseek-ai/dsh-session-persistence-jsonl' config: - root: !!js launcherSessionsRoot ?? './.sessions' + root: !!js path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'sessions') - id: subprocess name: '@deepseek-ai/dsh-subprocess-local' diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 00f3c373ad..709da4fc60 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -16,7 +16,7 @@ import type { FiberState } from 'cordis' import type { PatchOptions } from '@cordisjs/plugin-include' import yaml from 'js-yaml' import { boot, installFailLoud, loadEnv, loadOverlayPatches, loadPersonalPatches } from '@deepseek-ai/dsh-app-boot' -import { resolveDshHome, resolveSessionsRoot } from '@deepseek-ai/dsh-paths' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' // Empty type import carries the httpServer Context merge for the port read below. import type {} from '@deepseek-ai/dsh-host-webserver' @@ -189,11 +189,6 @@ export class AppCLIEntry { overrides.set(entryId, bag) } - // Source 0: computed engineering defaults. The session store is the one - // shared root every dsh surface resolves, so history follows the user across - // working directories instead of splitting per project. The profile - // (Source 1) overwrites this same field via last-write-wins in put(). - put('session-persistence-jsonl', 'root', resolveSessionsRoot()) // Source 1: profile json (missing file = empty; unmapped key = loud). for (const [key, value] of Object.entries(this.readProfile())) { diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 76705473a5..773d1e78e8 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -31,7 +31,7 @@ import { loadPersonalPatches, resolveConfigPath, } from '@deepseek-ai/dsh-app-boot' -import { resolveDshHome, resolveSessionsRoot } from '@deepseek-ai/dsh-paths' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { SessionId } from '@deepseek-ai/dsh-session' import { SESSION_QUERY_SQLITE_PATH_KEY } from '@deepseek-ai/dsh-session-query-sqlite' import { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop' @@ -39,7 +39,6 @@ import type { Context } from 'cordis' import { INITIAL_SKILL_KEY, MAIN_SESSION_ID_KEY, - SESSIONS_ROOT_KEY, TUI_GOODBYE_MESSAGE_KEY, type MainSessionIdentity, type TuiResumeHost, @@ -65,18 +64,6 @@ const SESSION_QUERY_DB = `session-query-${String(process.pid)}-${randomUUID()}.d // symlink, an arbitrary cwd). The agent is told where its own source lives. const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) -/** - * The value `dsh` provides on the {@link SESSIONS_ROOT_KEY} boot slot: its - * shared session-store root, `sessions` under the Harness home. Shared-store - * policy is the launcher's alone — the app bundle treats the slot as opaque and - * keeps a project-local fallback, so only `dsh` decides that sessions are - * shared across working directories (making `/resume` span every workspace). - * @returns the absolute session-store root this launcher shares. - */ -export function launcherSessionsRoot(): string { - return resolveSessionsRoot() -} - /* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers; the CLI PTY smoke drives this path end to end, personal overlay included */ /** @@ -238,7 +225,6 @@ export async function runTui( // Shared-store policy is the launcher's: sessions live in one root under // the Harness home across every cwd, so /resume sees every workspace. // The bundle treats the slot as opaque. - hostCtx.provide(SESSIONS_ROOT_KEY, launcherSessionsRoot()) // The agent-loop row reads this to bind `main`, and the tui row reads the // same id, so a personal overlay repointing the model route cannot drop // the session identity or desynchronise the two. diff --git a/apps/cli/tests/sessions-root.spec.ts b/apps/cli/tests/sessions-root.spec.ts deleted file mode 100644 index cb9982615a..0000000000 --- a/apps/cli/tests/sessions-root.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Pins the launcher side of the shared-session-store contract: `dsh` defaults - * its opaque `SESSIONS_ROOT_KEY` boot-slot value to `DSH_HOME/sessions`. The - * plugin side — the slot treated as opaque, explicit config winning, and a - * project-local fallback with no globality assumption — is pinned by - * the former bundled TUI tests. - */ - -import { join, resolve } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { launcherSessionsRoot } from '../src/tui.ts' - -afterEach(() => vi.unstubAllEnvs()) - -describe('launcherSessionsRoot', () => { - it('defaults the boot slot to sessions under DSH_HOME', () => { - vi.stubEnv('DSH_HOME', '/tmp/dsh-slot-home') - expect(launcherSessionsRoot()).toBe(resolve(join('/tmp/dsh-slot-home', 'sessions'))) - }) -}) diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index fc6c54c293..392cb0e356 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -182,8 +182,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 } } @@ -228,16 +226,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. * diff --git a/packages/util/paths/src/index.ts b/packages/util/paths/src/index.ts index 85a6c881cd..c54a5e0a5f 100644 --- a/packages/util/paths/src/index.ts +++ b/packages/util/paths/src/index.ts @@ -52,27 +52,6 @@ export function resolveDshHome(configured?: string, env: Record = process.env, -): string { - return join(resolveDshHome(configuredHome, env), SESSIONS_DIR_NAME) -} - /** * Describe a resolved harness home symbolically for user-facing display. * diff --git a/packages/util/paths/tests/paths.spec.ts b/packages/util/paths/tests/paths.spec.ts index 5d41b52c44..6e1b94b1e9 100644 --- a/packages/util/paths/tests/paths.spec.ts +++ b/packages/util/paths/tests/paths.spec.ts @@ -4,12 +4,10 @@ import { describe, expect, it } from 'vitest' import { DEFAULT_DSH_HOME_DISPLAY, DSH_HOME_DIR_NAME, - SESSIONS_DIR_NAME, defaultDshHome, dshHomeDisplay, expandHomePath, resolveDshHome, - resolveSessionsRoot, } from '@deepseek-ai/dsh-paths' describe('dsh path helpers', () => { @@ -40,15 +38,6 @@ describe('dsh path helpers', () => { expect(resolveDshHome(undefined, { DSH_HOME: ' ' })).toBe(defaultDshHome()) }) - it('resolves the session store under the home it was given, by the same precedence', () => { - expect(SESSIONS_DIR_NAME).toBe('sessions') - expect(resolveSessionsRoot('/tmp/explicit-dsh', { DSH_HOME: '~/env-dsh' })) - .toBe(join(resolve('/tmp/explicit-dsh'), 'sessions')) - expect(resolveSessionsRoot(undefined, { DSH_HOME: '~/env-dsh' })) - .toBe(join(homedir(), 'env-dsh', 'sessions')) - expect(resolveSessionsRoot(undefined, {})).toBe(join(defaultDshHome(), 'sessions')) - }) - it('labels a resolved home by whether it is the default root', () => { expect(dshHomeDisplay(resolve(defaultDshHome()))).toBe('~/.dsh') expect(dshHomeDisplay('/some/other/root')).toBe('$DSH_HOME') From c086de20743c80dc3c6bf984b0417aad8d9de2bb Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 10:34:21 +0800 Subject: [PATCH 61/77] fix(docs): align catalogs with consolidated CLI --- ...7-23-client-plugin-loading-model.i18n.yaml | 4 +- ...tree-boot-and-transport-layering.i18n.yaml | 4 +- ...-native-typescript-source-launch.i18n.yaml | 4 +- ...8-launcher-owned-resume-identity.i18n.yaml | 4 +- ...cated-full-screen-tui-front-door.i18n.yaml | 4 +- ...07-24-web-session-model-selector.i18n.yaml | 4 +- ...026-07-28-cross-workspace-resume.i18n.yaml | 4 +- ...-20-remove-stdio-and-echo-agents.i18n.yaml | 2 +- ...7-29-shared-base-config-overlays.i18n.yaml | 4 +- ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 +- apps/cli/composition.md | 52 ++-------------- docs/capability-seams.md | 4 +- docs/config-catalog.md | 61 +------------------ docs/cordis-catalog/services.md | 2 +- .../cordis-tutorial/01-first-plugin.i18n.yaml | 4 +- docs/development.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/graph-atlas.md | 2 +- docs/user/guide/config.i18n.yaml | 4 +- docs/user/guide/quickstart.i18n.yaml | 4 +- examples/README.i18n.yaml | 4 +- examples/README.md | 2 +- examples/README.zh.md | 2 +- examples/headless-agent/composition.md | 2 +- packages/core/agent-loop/src/index.ts | 2 +- packages/host/README.i18n.yaml | 4 +- packages/host/README.md | 2 +- packages/host/README.zh.md | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/todo/README.i18n.yaml | 4 +- packages/todo/README.md | 2 +- packages/todo/README.zh.md | 2 +- packages/todo/tool-todo/README.i18n.yaml | 4 +- packages/todo/tool-todo/README.md | 2 +- packages/todo/tool-todo/README.zh.md | 2 +- scripts/gen-doc-graphs.ts | 16 ++--- 38 files changed, 66 insertions(+), 171 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index 229a5fb221..f140786632 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md -2026-07-23-client-plugin-loading-model.md: ef9f14f2a150b0845ff89939bce5f8863dc15707 -2026-07-23-client-plugin-loading-model.zh.md: 71dc1e2303b657a3793b4ddd5c263337177ad671 +2026-07-23-client-plugin-loading-model.md: b0873b7aa7bccd3d613f3113fa18207770952e5d +2026-07-23-client-plugin-loading-model.zh.md: f3472dbfc5a78924e77337bf92ce5983c8492c4c diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index 32dd80bff1..aa927a2e7d 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: af9d5b4d19c96da186be85e16681ba9c38f2a25a -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 475b546e05ce7d9ce405266585882ffd36a27fad +2026-07-24-web-config-tree-boot-and-transport-layering.md: a2080024d36d54162f4f4aa79896e51efd708f59 +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 1c430bf2939f9f556f378bdeb78872a0091a592d diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml index 9f165ccbf1..fd0e926ace 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md -2026-07-28-dsh-native-typescript-source-launch.md: 2e69a93f74bb056ce654086641c3123c499a3ffb -2026-07-28-dsh-native-typescript-source-launch.zh.md: 8be7f8527de8b9ff4eaadac9af3137fca8d153ac +2026-07-28-dsh-native-typescript-source-launch.md: 773f831ec2b116d4908fcd5dc818df78c5deee5e +2026-07-28-dsh-native-typescript-source-launch.zh.md: 0602aa1ba079fcd5bffb2fe3989da9a4f62763fb diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml index 294898f6dd..8fc7c8966f 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md -2026-07-28-launcher-owned-resume-identity.md: 775f57c019a0dafbb2cfb724a8fb140610234032 -2026-07-28-launcher-owned-resume-identity.zh.md: 94e1de1402586186f6b184baeb8873b1e2868551 +2026-07-28-launcher-owned-resume-identity.md: e11431716305ff18e951ca45921c71dcd105974c +2026-07-28-launcher-owned-resume-identity.zh.md: e4078023cb1519288f0367f8973788f361aaa0bd diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml index 330dacea22..05355021ba 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md -2026-07-17-dedicated-full-screen-tui-front-door.md: 0f9492509a4fedcf1fcd16deed3b9cd570e3ba18 -2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 0d5a508d200ee39d751070854ce9b93f78ad4121 +2026-07-17-dedicated-full-screen-tui-front-door.md: 47f5b7f94ce56d592c6a7897d8bcd470e12a0a60 +2026-07-17-dedicated-full-screen-tui-front-door.zh.md: bbbfaafd8c5fc382dad070daf1eba26a0c6dcedd diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml index 1b4365fdcf..b1e6e8b7d1 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md -2026-07-24-web-session-model-selector.md: 7e0355e272c6b5c62df5eb50acce7bc5de5d9795 -2026-07-24-web-session-model-selector.zh.md: d5cd7eb7e679901fcd62945af7b19ede42e74a9d +2026-07-24-web-session-model-selector.md: 003c0b5ac1c6963701e1d93e4c3ff8615fc45dd5 +2026-07-24-web-session-model-selector.zh.md: ba76b87b9bd43fff97bf9ea76624f5f75b10e135 diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml index cd42eaf755..4c6c5e1d7a 100644 --- a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md -2026-07-28-cross-workspace-resume.md: be455496346b9242585c1aace18f4f55cba905c0 -2026-07-28-cross-workspace-resume.zh.md: 0384cf204e51e4086b05c75e691f59d6b60a7d11 +2026-07-28-cross-workspace-resume.md: 500ada299950f2a4640929a7bac597861b3cc207 +2026-07-28-cross-workspace-resume.zh.md: 01930e8651b6276e366fcfef1bf1d1e0a2a72464 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml index 68cca7bc54..c40031c21f 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md -2026-07-20-remove-stdio-and-echo-agents.md: 4b6a41489b3a1031aad6134052273035ea208e30 +2026-07-20-remove-stdio-and-echo-agents.md: 72b8f5e0eb161acb7152d1b1dd4e455addf36dbf 2026-07-20-remove-stdio-and-echo-agents.zh.md: 95af2e74ccce4738fed713c3c58904c48517a479 diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml index bd8e785272..2337dc6e40 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md -2026-07-29-shared-base-config-overlays.md: 006cc6b08f41648f6a0cd0512cd51fc26af96aab -2026-07-29-shared-base-config-overlays.zh.md: 0bc95c78d1e13c2d57f54e1719c07a4823d2b1d3 +2026-07-29-shared-base-config-overlays.md: 4b9b8de2f07bf1747ed7aa50d67740df768c7491 +2026-07-29-shared-base-config-overlays.zh.md: c0ccc5880909afd1dec0cbb387067a3d72c1ae16 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 71a866630f..31cb013bee 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: 763c6b4b06e951932626babb7a3f48261a475e1d -2026-07-24-web-gui-browser-e2e-lane.zh.md: 978148fec0c4ae1c177f181802ec980f2cf196a1 +2026-07-24-web-gui-browser-e2e-lane.md: 2a3c06080279e7efaf4f089877a25bf7e4f16921 +2026-07-24-web-gui-browser-e2e-lane.zh.md: 2df1728d18fe92bad0cf241628f5cf25638855a6 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 53d1b3f166..1cf07c1d4b 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -1,13 +1,13 @@ -# dsh TUI Composition +# TUI Agent App Composition -The TUI agent combines the real DeepSeek adapter, coding tools, compaction, subagents, and workflows with the full-screen terminal app package. +The TUI surface combines the shared CLI base with its surface overlay and full-screen terminal package. ```mermaid flowchart LR - cfg["apps/cli (dsh)
base.cordis.yml + tui.cordis.yml"] + cfg["apps/cli/config
cordis.yml"] plugin_tui_timer["timer
@cordisjs/plugin-timer"] cfg --> plugin_tui_timer plugin_tui_hmr["hmr
@cordisjs/plugin-hmr"] @@ -98,34 +98,6 @@ flowchart LR cfg --> plugin_tui_fs_local plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_tui_llm_deepseek - plugin_tui_invariants["invariants
@deepseek-ai/dsh-invariants"] - cfg --> plugin_tui_invariants - plugin_tui_session_invariant["session-invariant
@deepseek-ai/dsh-session/invariant"] - cfg --> plugin_tui_session_invariant - plugin_tui_agent_invariant["agent-invariant
@deepseek-ai/dsh-agent/invariant"] - cfg --> plugin_tui_agent_invariant - plugin_tui_scope_invariant["scope-invariant
@deepseek-ai/dsh-scope/invariant"] - cfg --> plugin_tui_scope_invariant - plugin_tui_agent_loop_invariant["agent-loop-invariant
@deepseek-ai/dsh-agent-loop/invariant"] - cfg --> plugin_tui_agent_loop_invariant - plugin_tui_session_checkpoint_policy["session-checkpoint-policy
@deepseek-ai/dsh-session-checkpoint-policy"] - cfg --> plugin_tui_session_checkpoint_policy - plugin_tui_session_query_sqlite["session-query-sqlite
@deepseek-ai/dsh-session-query-sqlite"] - cfg --> plugin_tui_session_query_sqlite - plugin_tui_session_reference["session-reference
@deepseek-ai/dsh-session-reference"] - cfg --> plugin_tui_session_reference - plugin_tui_tool_result_prune["tool-result-prune
@deepseek-ai/dsh-compact-tool-result-prune"] - cfg --> plugin_tui_tool_result_prune - plugin_tui_tool_goal["tool-goal
@deepseek-ai/dsh-tool-goal"] - cfg --> plugin_tui_tool_goal - plugin_tui_tool_ralph["tool-ralph
@deepseek-ai/dsh-tool-ralph"] - cfg --> plugin_tui_tool_ralph - plugin_tui_tui_prompt["tui-prompt
@deepseek-ai/dsh-tui/prompt"] - cfg --> plugin_tui_tui_prompt - plugin_tui_tui["tui
@deepseek-ai/dsh-tui"] - cfg --> plugin_tui_tui - plugin_tui_tool_ask_user["tool-ask-user
@deepseek-ai/dsh-tool-ask-user"] - cfg --> plugin_tui_tool_ask_user ``` | Plugin id | Package / module | @@ -175,21 +147,7 @@ flowchart LR | `agent-loop` | `@deepseek-ai/dsh-agent-loop` | | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -| `invariants` | `@deepseek-ai/dsh-invariants` | -| `session-invariant` | `@deepseek-ai/dsh-session/invariant` | -| `agent-invariant` | `@deepseek-ai/dsh-agent/invariant` | -| `scope-invariant` | `@deepseek-ai/dsh-scope/invariant` | -| `agent-loop-invariant` | `@deepseek-ai/dsh-agent-loop/invariant` | -| `session-checkpoint-policy` | `@deepseek-ai/dsh-session-checkpoint-policy` | -| `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` | -| `session-reference` | `@deepseek-ai/dsh-session-reference` | -| `tool-result-prune` | `@deepseek-ai/dsh-compact-tool-result-prune` | -| `tool-goal` | `@deepseek-ai/dsh-tool-goal` | -| `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` | -| `tui-prompt` | `@deepseek-ai/dsh-tui/prompt` | -| `tui` | `@deepseek-ai/dsh-tui` | -| `tool-ask-user` | `@deepseek-ai/dsh-tool-ask-user` | -Source configs: [`apps/cli/config/base.cordis.yml`](config/base.cordis.yml), [`apps/cli/config/tui.cordis.yml`](config/tui.cordis.yml). +Source config: [`apps/cli/config/base.cordis.yml`](config/base.cordis.yml). -Maintenance mode: hybrid: the leaf plugin list is parsed from its shipped config files; app package expansion is curated from package source. +Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 46c95d3342..f5c9e8fe01 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -88,6 +88,7 @@ flowchart LR pkg_skill_local["skill-local"] svc_agents["ctx.agents
Agent service"] pkg_acp["acp"] + pkg_tui_demo["tui-demo"] svc_agentLoop["ctx.agentLoop
Concrete loop driver"] pkg_agent_spine_demo["agent-spine-demo"] pkg_goal["goal"] @@ -242,6 +243,7 @@ flowchart LR svc_agents --> pkg_agent_loop svc_agents --> pkg_cli_demo svc_agents --> pkg_subagent_inprocess + svc_agents --> pkg_tui_demo svc_approval --> pkg_tool_bash svc_approval --> pkg_tools svc_bash --> pkg_hooks_claude @@ -355,7 +357,7 @@ flowchart LR | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | | `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/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | +| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), `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. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp) | - | The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 191d96b255..7b736400ca 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -108,7 +108,7 @@ export interface Config { Depends on: [`AgentOptions`](core-data-structures/core.md) · [`SessionId`](core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:155`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:211`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-agent-spine-demo` @@ -1133,7 +1133,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' Depends on: [`SessionQueryConfig`](../packages/session-query/session-query/src/index.ts) -Source: [`packages/session-query/session-query-sqlite/src/index.ts:76`](../packages/session-query/session-query-sqlite/src/index.ts) +Source: [`packages/session-query/session-query-sqlite/src/index.ts:86`](../packages/session-query/session-query-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-reference` @@ -1965,63 +1965,6 @@ export interface TuiThemeConfig { Source: [`packages/ui/tui/src/config.ts:117`](../packages/ui/tui/src/config.ts) -## `@deepseek-ai/dsh-tui-demo` - -```ts config-catalog -/** 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 - /** 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 - /** Generic background-task controls forwarded through agent-spine-demo; set false to omit them. */ - toolTasks?: NonNullable - /** 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'] -} -``` - -Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`SessionReferenceConfig`](#deepseek-aidsh-session-reference) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) - -Source: [`packages/examples/tui-demo/src/index.ts:39`](../packages/examples/tui-demo/src/index.ts) - ## `@deepseek-ai/dsh-typert-loader` Requires: `typert` · `loader` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 43e0786c1a..de4203a270 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2197,7 +2197,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:247`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:235`](../../packages/ui/tui/src/index.ts) ## `ctx.typert` — `TypertRegistry` diff --git a/docs/cordis-tutorial/01-first-plugin.i18n.yaml b/docs/cordis-tutorial/01-first-plugin.i18n.yaml index 2d49ea020d..d2e91da389 100644 --- a/docs/cordis-tutorial/01-first-plugin.i18n.yaml +++ b/docs/cordis-tutorial/01-first-plugin.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/01-first-plugin.md -01-first-plugin.md: 060f7d6296799d8db9c85bc7404a7b729d455cd2 -01-first-plugin.zh.md: 289df3d426fc4ffefd8a5439c72a6089b9745a2f +01-first-plugin.md: 0af2b8a292c2968c37e633d620192414479caafc +01-first-plugin.zh.md: cbea3ca2f7d875f481ef6a17749a5fac9774de1b diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 306a1d2d08..c456a02a31 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: 859de959dc5d93c2f0ddbe5c7f700d4bdaf9e09b -development.zh.md: faa6a07731deed77663727b3ee1f0fe060580c53 +development.md: f58cad7d361def14667fa66017cb003b74d70749 +development.zh.md: 88ddd8483c234bdf1c1fd0bcda9df3ca02ea6fa4 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 5a7eaa5d17..364f06fd19 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:148`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:157`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | | `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:221`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | diff --git a/docs/graph-atlas.md b/docs/graph-atlas.md index ac15bac685..909794c428 100644 --- a/docs/graph-atlas.md +++ b/docs/graph-atlas.md @@ -12,7 +12,7 @@ The process decision behind this index is recorded in [the documentation graph A | [module dependency graph](module-graph.md) | `generated` | | [tool schema catalog and package map](tool-catalog.md) | `generated` | | [capability seams and core services](capability-seams.md) | `hybrid generated` | -| [dsh TUI composition](../apps/cli/composition.md) | `hybrid generated` | +| [../apps/cli/composition.md](../apps/cli/composition.md) | `generated` | | [headless-agent app composition](../examples/headless-agent/composition.md) | `hybrid generated` | | [acp-agent app composition](../examples/acp-agent/composition.md) | `hybrid generated` | | [event producer/consumer matrix](event-producer-consumer.md) | `hybrid generated` | diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml index bf4ccbc63d..85ccd636e0 100644 --- a/docs/user/guide/config.i18n.yaml +++ b/docs/user/guide/config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/config.md -config.md: 34a2a902e11fae36b21affc136be70c2e7bd89f9 -config.zh.md: 62d2e35c4501be2cea9d58f2c0d54c3c770e8a08 +config.md: 00d103be990f3e5af315ab7667771b01be96ba5d +config.zh.md: a296619c3f3d68726efc4fc13703a510719fc008 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index 8105617a8d..703e6ec391 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/quickstart.md -quickstart.md: be41d00d0f618d62a0f6ee2350cbc5f43c76d981 -quickstart.zh.md: f592fdb3fb9a71f19a5ca356f586667d0674ea49 +quickstart.md: 1f01c7f4c9935c5a4772f6c702f023e691d911fc +quickstart.zh.md: 8b5d4608525a603725136e1568aef7217756293f diff --git a/examples/README.i18n.yaml b/examples/README.i18n.yaml index 6ac301268c..d7c82ac95e 100644 --- a/examples/README.i18n.yaml +++ b/examples/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/README.md -README.md: eb425ad152579ee10bbd54e99c667606fc8a649a -README.zh.md: 5f00b4471b516bb78679f09b41dcb1bdbc3ebdbc +README.md: 61aff168c656082d20c12416b62512ac17f445cb +README.zh.md: 50fcaae6ee2997055d3c97480f2e5bed0983fc5e diff --git a/examples/README.md b/examples/README.md index fea24a51e5..61aff168c6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -14,7 +14,7 @@ Run with: `pnpm run demo:headless "task"` (needs `DEEPSEEK_API_KEY`). See [headl An **overlay** over the shipped TUI that reduces the model-facing registry to the `run_code` transport, so the model batches tool work into TypeScript programs instead of one call per turn. -Run with: `pnpm run demo:code-mode` (needs `DEEPSEEK_API_KEY`). See [code-mode/README.md](code-mode/README.md). The interactive agent itself is not an example: `pnpm run demo:tui` boots [`apps/cli/config/tui.cordis.yml`](../apps/cli/config/tui.cordis.yml) over the shared base, and its PTY and snapshot scenarios live in `apps/cli/tests/`. +Run with: `pnpm run demo:code-mode` (needs `DEEPSEEK_API_KEY`). See [../apps/cli/README.md](../apps/cli/README.md). The interactive agent itself is not an example: `pnpm run demo:tui` boots [`apps/cli/config/tui.cordis.yml`](../apps/cli/config/tui.cordis.yml) over the shared base, and its PTY and snapshot scenarios live in `apps/cli/tests/`. ## jsonrpc-agent diff --git a/examples/README.zh.md b/examples/README.zh.md index 8fbb2312f4..50fcaae6ee 100644 --- a/examples/README.zh.md +++ b/examples/README.zh.md @@ -14,7 +14,7 @@ 叠加在交付 TUI 之上的 **overlay**:把面向模型的注册表收敛为 `run_code` 这一个传输,使模型把工具工作批量写进 TypeScript 程序,而不是每轮一次调用。 -运行:`pnpm run demo:code-mode`(需要 `DEEPSEEK_API_KEY`)。详见 [code-mode/README.md](code-mode/README.md)。交互式 agent 本身不再是示例:`pnpm run demo:tui` 在共享 base 之上启动 [`apps/cli/config/tui.cordis.yml`](../apps/cli/config/tui.cordis.yml),其 PTY 与快照场景位于 `apps/cli/tests/`。 +运行:`pnpm run demo:code-mode`(需要 `DEEPSEEK_API_KEY`)。详见 [../apps/cli/README.md](../apps/cli/README.md)。交互式 agent 本身不再是示例:`pnpm run demo:tui` 在共享 base 之上启动 [`apps/cli/config/tui.cordis.yml`](../apps/cli/config/tui.cordis.yml),其 PTY 与快照场景位于 `apps/cli/tests/`。 ## jsonrpc-agent diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index 7c55a80fdd..53a01260e1 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -76,4 +76,4 @@ flowchart LR Source config: [`examples/headless-agent/cordis.yml`](cordis.yml). -Maintenance mode: hybrid: the leaf plugin list is parsed from its shipped config files; app package expansion is curated from package source. +Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index f200154104..1342fbd885 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -174,7 +174,7 @@ export interface LauncherAgentIdentity { } /** Launcher-selected identities keyed by the configured agent's `id`. */ -export type ConfiguredAgentIdentities = Readonly> +export interface ConfiguredAgentIdentities extends Readonly> {} /** * Context key a launcher sets before any Loader entry mounts diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml index afc0e2a695..8c321a13b7 100644 --- a/packages/host/README.i18n.yaml +++ b/packages/host/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/README.md -README.md: d44770f70be16c12f44b78155089e092a3e9bba0 -README.zh.md: 2b6878b08be6489dcd510a0a0e0f0e833c2a8014 +README.md: 0417a1b8aec36d58ec0f690f397edcf9e015f982 +README.zh.md: 46516d187321029ed739d8c071f246bc1755b125 diff --git a/packages/host/README.md b/packages/host/README.md index d44770f70b..0417a1b8ae 100644 --- a/packages/host/README.md +++ b/packages/host/README.md @@ -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 | |---|---|---| diff --git a/packages/host/README.zh.md b/packages/host/README.zh.md index 2b6878b08b..46516d1873 100644 --- a/packages/host/README.zh.md +++ b/packages/host/README.zh.md @@ -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 键 | |---|---|---| diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 18202ed036..f52b5292a5 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 1f0daedc54888a1951bc83c474f83287aaf42307 -README.zh.md: abf5417cdbe93f1199c621ac101249986969da93 +README.md: f167d783854e43733e1b4b50f752536a139849b1 +README.zh.md: 4ae292b7ce9fb3ea1506babd92d40d5ca0ede081 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 1f0daedc54..f167d78385 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -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/cordis.yml`](../../../apps/cli/config/base.cordis.yml). ## Contract layer (`/api`) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index abf5417cdb..4ae292b7ce 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -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/cordis.yml`](../../../apps/cli/config/base.cordis.yml)。 ## 契约层(`/api`) diff --git a/packages/todo/README.i18n.yaml b/packages/todo/README.i18n.yaml index a7b1f6f529..015b8e1ceb 100644 --- a/packages/todo/README.i18n.yaml +++ b/packages/todo/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/todo/README.md -README.md: 1e5ae9a1583b9e9d3913fcd1dca7ef11a5f391fe -README.zh.md: a77f788a41353ea547059864dc7cf73ac5025219 +README.md: e16d1a3ff413d13d47f9b08a3cfddfedb76b5254 +README.zh.md: e3307d4f0acdb3f50db3f1e010286ad11668906a diff --git a/packages/todo/README.md b/packages/todo/README.md index 1e5ae9a158..e16d1a3ff4 100644 --- a/packages/todo/README.md +++ b/packages/todo/README.md @@ -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. diff --git a/packages/todo/README.zh.md b/packages/todo/README.zh.md index a77f788a41..e3307d4f0a 100644 --- a/packages/todo/README.zh.md +++ b/packages/todo/README.zh.md @@ -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 以及宿主/客户端运行时会根据会话事件渲染该持久化列表。 diff --git a/packages/todo/tool-todo/README.i18n.yaml b/packages/todo/tool-todo/README.i18n.yaml index 603d2cfef0..3072b330b8 100644 --- a/packages/todo/tool-todo/README.i18n.yaml +++ b/packages/todo/tool-todo/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/todo/tool-todo/README.md -README.md: b05ef43e7137dcf5678b1f1ad6d8c00b8a43baef -README.zh.md: 88a9f5d69ff52dcedbd8f27a4ace5fde3e0dc5a2 +README.md: 91f4bc6abd0c08f44f0a0a50393e4cdd5ddada70 +README.zh.md: b9582307ff5590bf34be91b776fa06841a68b41d diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index b05ef43e71..91f4bc6abd 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -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 diff --git a/packages/todo/tool-todo/README.zh.md b/packages/todo/tool-todo/README.zh.md index 88a9f5d69f..b9582307ff 100644 --- a/packages/todo/tool-todo/README.zh.md +++ b/packages/todo/tool-todo/README.zh.md @@ -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))。 ## 会话投影 diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 91aa8eb38c..b7a672029c 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -605,11 +605,11 @@ function stripYamlScalar(value: string): string { const APP_EXAMPLES = [ { id: 'tui', - rel: 'examples/tui-agent/composition.md', + rel: 'apps/cli/composition.md', title: 'TUI Agent App Composition', - label: 'examples/tui-agent', - config: 'examples/tui-agent/cordis.yml', - summary: 'The TUI agent combines the real DeepSeek adapter, coding tools, compaction, subagents, and workflows with the full-screen terminal app package.', + label: 'apps/cli/config', + config: 'apps/cli/config/base.cordis.yml', + summary: 'The TUI surface combines the shared CLI base with its surface overlay and full-screen terminal package.', }, { id: 'headless', @@ -619,14 +619,6 @@ const APP_EXAMPLES = [ config: 'examples/headless-agent/cordis.yml', summary: 'The headless demo combines the real DeepSeek adapter and coding capabilities with the one-shot app package, format-pure stdout, and one fresh persisted top-level session.', }, - { - id: 'cordis', - rel: 'examples/cordis-agent/composition.md', - title: 'Cordis Agent App Composition', - label: 'examples/cordis-agent', - config: 'examples/cordis-agent/cordis.yml', - summary: 'The self-referential demo puts @deepseek-ai/dsh-tool-cordis on the coding spine, letting the agent inspect its current-process runtime and mount or unmount in-memory temporary Plugins.', - }, { id: 'acp', rel: 'examples/acp-agent/composition.md', From f2746a7115c1991c50a135acb3d415f69046ef37 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 10:38:54 +0800 Subject: [PATCH 62/77] docs: keep todo package unchanged --- packages/todo/README.i18n.yaml | 4 ++-- packages/todo/README.md | 2 +- packages/todo/README.zh.md | 2 +- packages/todo/tool-todo/README.i18n.yaml | 4 ++-- packages/todo/tool-todo/README.md | 2 +- packages/todo/tool-todo/README.zh.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/todo/README.i18n.yaml b/packages/todo/README.i18n.yaml index 015b8e1ceb..a7b1f6f529 100644 --- a/packages/todo/README.i18n.yaml +++ b/packages/todo/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/todo/README.md -README.md: e16d1a3ff413d13d47f9b08a3cfddfedb76b5254 -README.zh.md: e3307d4f0acdb3f50db3f1e010286ad11668906a +README.md: 1e5ae9a1583b9e9d3913fcd1dca7ef11a5f391fe +README.zh.md: a77f788a41353ea547059864dc7cf73ac5025219 diff --git a/packages/todo/README.md b/packages/todo/README.md index e16d1a3ff4..1e5ae9a158 100644 --- a/packages/todo/README.md +++ b/packages/todo/README.md @@ -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](../ui/tui) 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](../examples/tui-demo) and the host/client runtime render the durable list from session events. diff --git a/packages/todo/README.zh.md b/packages/todo/README.zh.md index e3307d4f0a..a77f788a41 100644 --- a/packages/todo/README.zh.md +++ b/packages/todo/README.zh.md @@ -8,4 +8,4 @@ |---|---|---| | `tool-todo/` | 面向模型的 `todo_write` 工具;将完整列表写入会话日志(`todo/write`) | (注册到 `ctx.tools`) | -列表存在于事件溯源会话日志中(`SessionEventMap['todo/write']`,由 [`dsh-session`](../core/session) 拥有);本包是追加快照的轻量消费方。[TUI 应用](../ui/tui)等 UI 以及宿主/客户端运行时会根据会话事件渲染该持久化列表。 +列表存在于事件溯源会话日志中(`SessionEventMap['todo/write']`,由 [`dsh-session`](../core/session) 拥有);本包是追加快照的轻量消费方。[TUI 应用](../examples/tui-demo)等 UI 以及宿主/客户端运行时会根据会话事件渲染该持久化列表。 diff --git a/packages/todo/tool-todo/README.i18n.yaml b/packages/todo/tool-todo/README.i18n.yaml index 3072b330b8..603d2cfef0 100644 --- a/packages/todo/tool-todo/README.i18n.yaml +++ b/packages/todo/tool-todo/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/todo/tool-todo/README.md -README.md: 91f4bc6abd0c08f44f0a0a50393e4cdd5ddada70 -README.zh.md: b9582307ff5590bf34be91b776fa06841a68b41d +README.md: b05ef43e7137dcf5678b1f1ad6d8c00b8a43baef +README.zh.md: 88a9f5d69ff52dcedbd8f27a4ace5fde3e0dc5a2 diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index 91f4bc6abd..b05ef43e71 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -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](../../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)). +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)). ## Session projection diff --git a/packages/todo/tool-todo/README.zh.md b/packages/todo/tool-todo/README.zh.md index b9582307ff..88a9f5d69f 100644 --- a/packages/todo/tool-todo/README.zh.md +++ b/packages/todo/tool-todo/README.zh.md @@ -20,7 +20,7 @@ ## 渲染 -规范结果为 `{ 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))。 +规范结果为 `{ 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))。 ## 会话投影 From 6b57be33719a078e0b410d377281a43709b4d8dc Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 10:39:35 +0800 Subject: [PATCH 63/77] docs: keep host package unchanged --- packages/host/README.i18n.yaml | 4 ++-- packages/host/README.md | 2 +- packages/host/README.zh.md | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 ++-- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml index 8c321a13b7..afc0e2a695 100644 --- a/packages/host/README.i18n.yaml +++ b/packages/host/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/README.md -README.md: 0417a1b8aec36d58ec0f690f397edcf9e015f982 -README.zh.md: 46516d187321029ed739d8c071f246bc1755b125 +README.md: d44770f70be16c12f44b78155089e092a3e9bba0 +README.zh.md: 2b6878b08be6489dcd510a0a0e0f0e833c2a8014 diff --git a/packages/host/README.md b/packages/host/README.md index 0417a1b8ae..d44770f70b 100644 --- a/packages/host/README.md +++ b/packages/host/README.md @@ -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/config/base.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/cordis.yml) serving [`apps/web`](../../apps/web/). All **product** packages. | Package | Role | ctx key | |---|---|---| diff --git a/packages/host/README.zh.md b/packages/host/README.zh.md index 46516d1873..2b6878b08b 100644 --- a/packages/host/README.zh.md +++ b/packages/host/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承载它的纯 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合后的应用是 [`apps/cli`](../../apps/cli/config/base.cordis.yml),它负责服务 [`apps/web`](../../apps/web/)。全部为**产品**包。 +dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承载它的纯 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合后的应用是 [`apps/cli`](../../apps/cli/cordis.yml),它负责服务 [`apps/web`](../../apps/web/)。全部为**产品**包。 | 包 | 角色 | ctx 键 | |---|---|---| diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index f52b5292a5..18202ed036 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: f167d783854e43733e1b4b50f752536a139849b1 -README.zh.md: 4ae292b7ce9fb3ea1506babd92d40d5ca0ede081 +README.md: 1f0daedc54888a1951bc83c474f83287aaf42307 +README.zh.md: abf5417cdbe93f1199c621ac101249986969da93 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index f167d78385..1f0daedc54 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -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/config/base.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/cordis.yml`](../../../apps/cli/cordis.yml). ## Contract layer (`/api`) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 4ae292b7ce..abf5417cdb 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -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/config/base.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/cordis.yml`](../../../apps/cli/cordis.yml)。 ## 契约层(`/api`) From 59bd220215b131cd8e53235ec863c3b5026455bb Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 10:45:08 +0800 Subject: [PATCH 64/77] docs: repair links to consolidated CLI --- packages/host/README.i18n.yaml | 4 ++-- packages/host/README.md | 2 +- packages/host/README.zh.md | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 ++-- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/todo/README.i18n.yaml | 4 ++-- packages/todo/README.md | 2 +- packages/todo/README.zh.md | 2 +- packages/todo/tool-todo/README.i18n.yaml | 4 ++-- packages/todo/tool-todo/README.md | 2 +- packages/todo/tool-todo/README.zh.md | 2 +- 12 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml index afc0e2a695..8c321a13b7 100644 --- a/packages/host/README.i18n.yaml +++ b/packages/host/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/README.md -README.md: d44770f70be16c12f44b78155089e092a3e9bba0 -README.zh.md: 2b6878b08be6489dcd510a0a0e0f0e833c2a8014 +README.md: 0417a1b8aec36d58ec0f690f397edcf9e015f982 +README.zh.md: 46516d187321029ed739d8c071f246bc1755b125 diff --git a/packages/host/README.md b/packages/host/README.md index d44770f70b..0417a1b8ae 100644 --- a/packages/host/README.md +++ b/packages/host/README.md @@ -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 | |---|---|---| diff --git a/packages/host/README.zh.md b/packages/host/README.zh.md index 2b6878b08b..46516d1873 100644 --- a/packages/host/README.zh.md +++ b/packages/host/README.zh.md @@ -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 键 | |---|---|---| diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 18202ed036..f52b5292a5 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 1f0daedc54888a1951bc83c474f83287aaf42307 -README.zh.md: abf5417cdbe93f1199c621ac101249986969da93 +README.md: f167d783854e43733e1b4b50f752536a139849b1 +README.zh.md: 4ae292b7ce9fb3ea1506babd92d40d5ca0ede081 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 1f0daedc54..f167d78385 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -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/cordis.yml`](../../../apps/cli/config/base.cordis.yml). ## Contract layer (`/api`) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index abf5417cdb..4ae292b7ce 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -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/cordis.yml`](../../../apps/cli/config/base.cordis.yml)。 ## 契约层(`/api`) diff --git a/packages/todo/README.i18n.yaml b/packages/todo/README.i18n.yaml index a7b1f6f529..015b8e1ceb 100644 --- a/packages/todo/README.i18n.yaml +++ b/packages/todo/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/todo/README.md -README.md: 1e5ae9a1583b9e9d3913fcd1dca7ef11a5f391fe -README.zh.md: a77f788a41353ea547059864dc7cf73ac5025219 +README.md: e16d1a3ff413d13d47f9b08a3cfddfedb76b5254 +README.zh.md: e3307d4f0acdb3f50db3f1e010286ad11668906a diff --git a/packages/todo/README.md b/packages/todo/README.md index 1e5ae9a158..e16d1a3ff4 100644 --- a/packages/todo/README.md +++ b/packages/todo/README.md @@ -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. diff --git a/packages/todo/README.zh.md b/packages/todo/README.zh.md index a77f788a41..e3307d4f0a 100644 --- a/packages/todo/README.zh.md +++ b/packages/todo/README.zh.md @@ -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 以及宿主/客户端运行时会根据会话事件渲染该持久化列表。 diff --git a/packages/todo/tool-todo/README.i18n.yaml b/packages/todo/tool-todo/README.i18n.yaml index 603d2cfef0..3072b330b8 100644 --- a/packages/todo/tool-todo/README.i18n.yaml +++ b/packages/todo/tool-todo/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/todo/tool-todo/README.md -README.md: b05ef43e7137dcf5678b1f1ad6d8c00b8a43baef -README.zh.md: 88a9f5d69ff52dcedbd8f27a4ace5fde3e0dc5a2 +README.md: 91f4bc6abd0c08f44f0a0a50393e4cdd5ddada70 +README.zh.md: b9582307ff5590bf34be91b776fa06841a68b41d diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index b05ef43e71..91f4bc6abd 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -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 diff --git a/packages/todo/tool-todo/README.zh.md b/packages/todo/tool-todo/README.zh.md index 88a9f5d69f..b9582307ff 100644 --- a/packages/todo/tool-todo/README.zh.md +++ b/packages/todo/tool-todo/README.zh.md @@ -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))。 ## 会话投影 From efcffa0495395c38bac09325076bfb84a2b142f8 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 10:53:43 +0800 Subject: [PATCH 65/77] fix(cli): evaluate session root in config context --- apps/cli/config/base.cordis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index a6f84d1c51..dfb3db3dcd 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -70,7 +70,7 @@ - id: session-persistence-jsonl name: '@deepseek-ai/dsh-session-persistence-jsonl' config: - root: !!js path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'sessions') + root: !!js process.env.DSH_HOME + '/sessions' - id: subprocess name: '@deepseek-ai/dsh-subprocess-local' From f224bc347bab7c8ae4dce6014b22683bfab12ca7 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 10:59:56 +0800 Subject: [PATCH 66/77] test(cli): follow consolidated config tree --- apps/cli/tests/fixtures/tui-scripted.cordis.yml | 4 ++-- apps/cli/tests/tui-keyless-smoke.e2e.ts | 14 -------------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/apps/cli/tests/fixtures/tui-scripted.cordis.yml b/apps/cli/tests/fixtures/tui-scripted.cordis.yml index bde14e4627..69921c7e86 100644 --- a/apps/cli/tests/fixtures/tui-scripted.cordis.yml +++ b/apps/cli/tests/fixtures/tui-scripted.cordis.yml @@ -10,13 +10,13 @@ # The scripted adapter replaces the DeepSeek one: no key, no network. A patch's # `name` is an assertion rather than a replacement, so the base row is disabled # and the adapter inserted. Relative specifiers resolve against the INCLUDED -# file's directory (apps/cli), because the include moves baseUrl to that tree. +# file's directory (apps/cli/config), because the include moves baseUrl there. - id: llm-deepseek disabled: true - insert: - id: scripted-llm - name: './tests/fixtures/tui-scripted-llm.ts' + name: '../tests/fixtures/tui-scripted-llm.ts' - id: agent-loop config: diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 4da6203477..59042892e1 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -12,7 +12,6 @@ import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts' const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) // `--config` layers an overlay over the shared base, so the default surface // needs no config argument at all; these are the overlays under test. -const codeModeConfigPath = fileURLToPath(new URL('../../../examples/code-mode/cordis.yml', import.meta.url)) const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) @@ -315,19 +314,6 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('boots the Code Mode overlay tree, renders its banner, and exits cleanly', async () => { - // The overlay's only keyless composition proof: the include+patch tree, - // worker code runtime, and one-tool registry all mount before the banner. - const output = await smoke({ - label: 'dsh code mode', - tempDirPrefix: 'dsh-tui-code-mode-', - configPath: codeModeConfigPath, - actions: [{ waitFor: 'TUI Code Mode ready.', send: '/exit\r' }], - }) - expect(output).toContain('TUI Code Mode ready.') - expect(output).toContain('\u001B[?2004l') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) - }) describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { From 93748e5b47ddc60f25cb9aff7ee02cedb3dedcbd Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 11:00:59 +0800 Subject: [PATCH 67/77] fix(cli): preserve DSH home fallback in config --- apps/cli/config/base.cordis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index dfb3db3dcd..f665c2ba6e 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -70,7 +70,7 @@ - id: session-persistence-jsonl name: '@deepseek-ai/dsh-session-persistence-jsonl' config: - root: !!js process.env.DSH_HOME + '/sessions' + root: !!js process.getBuiltinModule('node:path').join(process.env.DSH_HOME || process.getBuiltinModule('node:path').join(process.getBuiltinModule('node:os').homedir(), '.dsh'), 'sessions') - id: subprocess name: '@deepseek-ai/dsh-subprocess-local' From cd100a81f10e28ead4e7dd45b65c29cdafca2aa4 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 11:14:18 +0800 Subject: [PATCH 68/77] fix(cli): harden launcher lifecycle --- apps/cli/src/tui.ts | 26 +++++++-------------- packages/ui/app-boot/src/index.ts | 4 ++-- packages/ui/app-boot/tests/app-boot.spec.ts | 6 ++++- packages/ui/tui/src/index.ts | 4 +++- 4 files changed, 19 insertions(+), 21 deletions(-) diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 773d1e78e8..db10b2f06c 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -144,23 +144,15 @@ export async function runTui( const entry = process.argv[1] const execve = process.execve?.bind(process) const app: { current?: Context } = {} - // Resuming reproduces THIS invocation with a different id. Meta mode is a - // subcommand that rejects `--config`, while the default surface carries it, so - // both the in-place handoff and the printed command derive from one shape. - // `meta` is only reproducible for a target inside this checkout: it chdirs to - // SOURCE_ROOT itself, which would override any other workspace, so a - // cross-workspace resume takes the default surface and the caller supplies the - // directory instead. - const resumeArgs = (sessionId: string, targetCwd?: string): string[] => - workspace !== undefined && (targetCwd === undefined || targetCwd === workspace) - ? ['meta', `--resume=${sessionId}`] - : [ - `--resume=${sessionId}`, - // Both config flags must survive the handoff: resuming into a different - // tree than the session was created in would silently change the agent. - ...resolvedConfig !== undefined ? ['--config', resolvedConfig] : [], - ...resolvedConfigReplace !== undefined ? ['--config-replace', resolvedConfigReplace] : [], - ] + // Resume always enters the default surface because meta rejects parent + // options, including `--resume`. The resumed session already persists its cwd. + const resumeArgs = (sessionId: string, _targetCwd?: string): string[] => [ + `--resume=${sessionId}`, + // Both config flags must survive the handoff: resuming into a different + // tree than the session was created in would silently change the agent. + ...resolvedConfig !== undefined ? ['--config', resolvedConfig] : [], + ...resolvedConfigReplace !== undefined ? ['--config-replace', resolvedConfigReplace] : [], + ] // Mint the fresh id here rather than in the app bundle: the exit line names // the session to resume, so the launcher must know it before the tree boots. const identity: MainSessionIdentity = resumeSessionId === undefined diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 2a2460f18e..2c2a84c18b 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -208,7 +208,7 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void { * (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( @@ -218,10 +218,10 @@ export async function boot( prepare?: (ctx: Context) => Promise | void, ): Promise { 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: { diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 3da19e67c2..ba72902308 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -195,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 { diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 392cb0e356..7fb8cd8158 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1676,7 +1676,9 @@ export function apply(ctx: Context, config: Config): void { initialSkill === undefined ? {} : { initialSkill }, ), { terminal: new ProcessTerminal(), - exit: code => process.exit(code), + exit: (code) => { + void ctx.fiber.dispose().finally(() => { process.exit(code) }) + }, ...resumeHost === undefined ? {} : { handoffResume: (sessionId, cwd) => resumeHost.handoff(sessionId, cwd) }, ...goodbyeMessage === undefined ? {} : { goodbyeMessage }, }) From 9691c90b1c3a587812126970446d3394ef3a2187 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 11:21:01 +0800 Subject: [PATCH 69/77] test(docs): refresh translation prompt snapshot --- .../translation-prompt-v4/request-response.expected.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index acc18f64b4..78255907c3 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -16,11 +16,11 @@ }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`. When Git seeds a new worktree with another registered worktree's marker-backed hook path, the wrapper replaces that copied value with the new worktree's own path; command-scoped and other worktree-scoped paths must be integrated or removed explicitly.\n\nBefore enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files.\n\nAfter moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat 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.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `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 |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `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 |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost 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:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- 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.\n\nStatic 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).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe 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.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # oxlint .\npnpm run lint:fix # formatting-only ESLint, then oxlint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis-agent demo can inspect and modify its live plugin runtime and needs the same credentials:\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`. When Git seeds a new worktree with another registered worktree's marker-backed hook path, the wrapper replaces that copied value with the new worktree's own path; command-scoped and other worktree-scoped paths must be integrated or removed explicitly.\n\nBefore enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files.\n\nAfter moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat 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.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `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 |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `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 |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost 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:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- 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.\n\nStatic 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).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe 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.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # oxlint .\npnpm run lint:fix # formatting-only ESLint, then oxlint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`。当 Git 使用另一个已注册 worktree 中由所有权标记佐证的钩子路径初始化新 worktree 时,包装层会将这个复制值替换为新 worktree 自有的路径;命令作用域和其他 worktree 作用域的路径必须显式集成或移除。\n\n启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。\n\n检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 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)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # oxlint .\npnpm run lint:fix # formatting-only ESLint, then oxlint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis-agent 演示可以检查并修改其实时插件运行时,并需要相同的凭证:\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`。当 Git 使用另一个已注册 worktree 中由所有权标记佐证的钩子路径初始化新 worktree 时,包装层会将这个复制值替换为新 worktree 自有的路径;命令作用域和其他 worktree 作用域的路径必须显式集成或移除。\n\n启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。\n\n检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 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)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # oxlint .\npnpm run lint:fix # formatting-only ESLint, then oxlint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" }, { "role": "user", From 208944645ca604068858ce940b5dcb88da4dc64e Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 11:34:46 +0800 Subject: [PATCH 70/77] fix(cli): align shared config lifecycle docs --- .../2026-07-28-cross-workspace-resume.i18n.yaml | 4 ++-- .../feature/2026-07-28-cross-workspace-resume.md | 10 ++++------ .../feature/2026-07-28-cross-workspace-resume.zh.md | 10 ++++------ apps/cli/config/web.cordis.yml | 6 +++++- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml index 4c6c5e1d7a..7123353c03 100644 --- a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md -2026-07-28-cross-workspace-resume.md: 500ada299950f2a4640929a7bac597861b3cc207 -2026-07-28-cross-workspace-resume.zh.md: 01930e8651b6276e366fcfef1bf1d1e0a2a72464 +2026-07-28-cross-workspace-resume.md: 1ef6195b6f269a6dc8cf869902b7772ad48f00c2 +2026-07-28-cross-workspace-resume.zh.md: d31dc5f0470e20f86062edb4ad75c37a6160fe23 diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md index 500ada2999..1ef6195b6f 100644 --- a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md @@ -16,17 +16,15 @@ Finally, resume never changed directory. The host re-execs `dsh --resume=` t ## Decision -The dsh launcher supplies one session root under its Harness home through a boot slot, the picker gains a workspace scope, and the handoff carries the target directory. +The shared CLI configuration supplies one session root under the Harness home, the picker gains a workspace scope, and the handoff carries the target directory. -**Storage.** `dsh-paths` owns the location as `resolveSessionsRoot()` (`sessions` under the Harness home, by `resolveDshHome`'s precedence), but only the launcher assumes it: shared-store policy is the dsh CLI's, never a plugin's. The TUI surface provides the root through the `SESSIONS_ROOT_KEY` boot slot (`ctx.provide` before Loader entries mount) and `dsh web` patches the same root in `apps/cli/src/app-cli-entry.ts`. Two CLI surfaces computing that path independently is exactly the failure this change fixes — disjoint stores — so the fact gets one home rather than a `join` per caller, alongside the existing `registryRoot()` precedent for `run`. - -The shared base states that precedence in the row itself: `apps/cli/config/base.cordis.yml`'s `session-persistence-jsonl` row reads `root: !!js launcherSessionsRoot ?? './.sessions'`, so the launcher slot wins and a bare boot without one keeps the project-local default. Expressing it as the row's own `!!js` value rather than a schemastery `.default()` matters for the same reason it did in the bundle: a schema default would materialize before the slot could be read. An overlay or personal patch that states an explicit root always wins, which remains the correct choice for a hermetic deployment. +**Storage.** The shared base owns the default in `apps/cli/config/base.cordis.yml`: its `session-persistence-jsonl` row resolves `sessions` beneath `DSH_HOME`, with the standard `~/.dsh` fallback when the environment has not been initialized. TUI, Web, and headless therefore consume one default without launcher patches or a boot slot. An overlay or personal patch that states an explicit root replaces that row's whole `config` and remains the deployment's authoritative choice. **Scope, not exclusion.** A workspace other than the current one is a display scope rather than a disabled reason. `showResume()` summarizes every record and the `ResumePicker` owns a `scope` of `'workspace' | 'all'`, defaulting to the current workspace so the common case is unchanged. Tab toggles; the scope line names the active scope and the count the other holds; each row in the all-workspaces scope reports its own workspace, and that label joins the searchable text only in the scope that shows it. A toggle clears the query and selection so the highlighted row always belongs to the visible list, and the per-row workspace line makes a row one terminal row taller in that scope, which the visible-count budget accounts for. `summarizeResumeCandidate` therefore drops `'different workspace'` and gains `'session has no recorded workspace'`. That is a real new refusal rather than a rename: a header without `cwd` names no directory for the host to enter, so it cannot be handed off even though its log is intact. -**Handoff.** `TuiResumeHost.handoff` takes the target `cwd` beside the `SessionId`. `preflightResume` resolves both together and returns them, so the caller cannot re-derive a stale directory from the row it displayed — a record whose `cwd` moved between listing and preflight is resumed in the *re-read* directory, which is why the former "reject a moved cwd" behavior is now a handoff with the new path. The shipped host chdirs before disposing the app: an unreachable directory must reject while the caller can still restore the terminal, because after teardown no owner remains to report to. `resumeArgs` keeps the `meta` subcommand form only when the target is this checkout, since `dsh meta` chdirs to the harness source itself and would override any other workspace. +**Handoff.** `TuiResumeHost.handoff` takes the target `cwd` beside the `SessionId`. `preflightResume` resolves both together and returns them, so the caller cannot re-derive a stale directory from the row it displayed — a record whose `cwd` moved between listing and preflight is resumed in the *re-read* directory, which is why the former "reject a moved cwd" behavior is now a handoff with the new path. The shipped host chdirs before disposing the app: an unreachable directory must reject while the caller can still restore the terminal, because after teardown no owner remains to report to. Resume always uses the default `dsh --resume` surface because `meta` rejects parent options; the handoff already enters the persisted target directory. ## Alternatives considered @@ -48,4 +46,4 @@ The shared base states that precedence in the row itself: `apps/cli/config/base. ## Testing -TUI tests cover the default scope hiding other workspaces while reporting their count, Tab revealing them with per-row workspace labels, Tab back clearing the query and selection, searching by workspace label, a cwd-less record staying visible but disabled, and the handoff receiving both the id and the workspace re-read at preflight. The former "reject a moved cwd" case now asserts the handoff carries the new directory. `dsh-paths` tests pin `resolveSessionsRoot`'s precedence against `resolveDshHome`'s. `apps/cli` tests pin the project-local default and the derived `session-query.db` path. The keyless TUI snapshot pins both scopes of the selector, including the scope line, the per-row workspace lines, and the Tab hint in the footer. A manual cross-workspace resume verified at the process level that the replacement's working directory became the target workspace. +TUI tests cover the default scope hiding other workspaces while reporting their count, Tab revealing them with per-row workspace labels, Tab back clearing the query and selection, searching by workspace label, a cwd-less record staying visible but disabled, and the handoff receiving both the id and the workspace re-read at preflight. The former "reject a moved cwd" case now asserts the handoff carries the new directory. Built CLI PTY tests exercise the shared config default and the per-process derived query index. The keyless TUI snapshot pins both scopes of the selector, including the scope line, the per-row workspace lines, and the Tab hint in the footer. A manual cross-workspace resume verified at the process level that the replacement's working directory became the target workspace. diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md index 01930e8651..d31dc5f047 100644 --- a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md @@ -16,17 +16,15 @@ Status: implemented ## Decision -dsh 启动器通过启动槽位提供其 Harness home 下的同一个会话根目录,选择器获得 workspace 范围,交接过程携带目标目录。 +共享 CLI 配置提供 Harness home 下的同一个会话根目录,选择器获得 workspace 范围,交接过程携带目标目录。 -**存储。** `dsh-paths` 以 `resolveSessionsRoot()` 拥有该位置(按 `resolveDshHome` 的优先级,取 Harness home 下的 `sessions`),但只有启动器假定它:共享存储策略属于 dsh CLI,绝不属于插件。TUI 界面通过 `SESSIONS_ROOT_KEY` 启动槽位(在 Loader 条目挂载前 `ctx.provide`)提供该根目录,`dsh web` 则在 `apps/cli/src/app-cli-entry.ts` 中为同一根目录打补丁。CLI 的两处界面各自独立计算该路径,正是本次改动所修复的那种失败——互不相交的存储——因此这项事实只有一个归属,而不是每个调用方各做一次 `join`,这与 `run` 已有的 `registryRoot()` 先例一致。 - -共享 base 直接在配置项自身表达这一优先级:`apps/cli/config/base.cordis.yml` 的 `session-persistence-jsonl` 配置项写作 `root: !!js launcherSessionsRoot ?? './.sessions'`,因此启动器槽位优先,而没有槽位的裸启动则保留项目本地默认值。把它写成配置项自身的 `!!js` 取值、而不是 schemastery 的 `.default()`,其原因与在组合包中相同:schema 默认值会在能够读取槽位之前就物化。若 overlay 或个人 patch 显式声明了根目录,则始终以其为准——对于要求自洽封闭的部署,这仍是正确选择。 +**存储。** 共享 base 在 `apps/cli/config/base.cordis.yml` 中拥有默认值:其 `session-persistence-jsonl` 配置项把 `sessions` 解析到 `DSH_HOME` 下;环境尚未初始化时则使用标准的 `~/.dsh` 回退值。因此 TUI、Web 与 headless 使用同一个默认值,无需启动器补丁或启动槽位。若 overlay 或个人 patch 显式声明根目录,它会整体替换该配置项的 `config`,并继续作为部署的权威选择。 **是范围,不是排除。** 当前 workspace 之外的 workspace 是一种展示范围,而不是禁用理由。`showResume()` 汇总每一条记录,`ResumePicker` 持有一个 `'workspace' | 'all'` 的 `scope`,默认为当前 workspace,因此常见场景毫无变化。Tab 切换范围;范围行会说明当前生效的范围,以及另一个范围下的数量;在全 workspace 范围中每一行都报告自己的 workspace,而该标签只在展示它的范围里才加入可搜索文本。切换范围会清空查询和选中项,使高亮行始终属于可见列表;而逐行的 workspace 行会让该范围下的每一行在终端里多占一行,可见条数预算已经把这一点计入。 因此 `summarizeResumeCandidate` 去掉了 `'different workspace'`,并新增 `'session has no recorded workspace'`。这是一条真正新增的拒绝理由,而不是改名:没有 `cwd` 的头部没有指明任何目录供宿主进入,所以即便它的日志完好也无法完成交接。 -**交接。** `TuiResumeHost.handoff` 在 `SessionId` 之外还接收目标 `cwd`。`preflightResume` 把两者一起解析并一起返回,因此调用方无法从它展示过的那一行里重新推导出一个陈旧目录——在列表展示与预检之间 `cwd` 发生了移动的记录,会在*重新读取到的*目录中恢复,这也是原先「拒绝已移动的 cwd」的行为如今变成携带新路径完成交接的原因。已交付的宿主在释放应用之前切换目录:不可达的目录必须在调用方还能恢复终端时就拒绝,因为拆卸之后已经没有任何所有者可供汇报。`resumeArgs` 只在目标就是本 checkout 时才保留 `meta` 子命令形式,因为 `dsh meta` 会切换到 harness 源码本身,从而覆盖任何其他 workspace。 +**交接。** `TuiResumeHost.handoff` 在 `SessionId` 之外还接收目标 `cwd`。`preflightResume` 把两者一起解析并一起返回,因此调用方无法从它展示过的那一行里重新推导出一个陈旧目录——在列表展示与预检之间 `cwd` 发生了移动的记录,会在*重新读取到的*目录中恢复,这也是原先「拒绝已移动的 cwd」的行为如今变成携带新路径完成交接的原因。已交付的宿主在释放应用之前切换目录:不可达的目录必须在调用方还能恢复终端时就拒绝,因为拆卸之后已经没有任何所有者可供汇报。恢复始终使用默认的 `dsh --resume` 界面,因为 `meta` 会拒绝父级选项;交接过程已经进入持久化保存的目标目录。 ## Alternatives considered @@ -48,4 +46,4 @@ dsh 启动器通过启动槽位提供其 Harness home 下的同一个会话根 ## Testing -TUI 测试覆盖默认范围隐藏其他 workspace 但报告其数量、Tab 显示它们并带上逐行 workspace 标签、再按 Tab 返回时清空查询与选中项、按 workspace 标签搜索、无 cwd 的记录仍可见但不可选,以及交接同时收到 id 和在预检时重新读取到的 workspace。原先「拒绝已移动的 cwd」的用例现在断言交接携带新目录。`dsh-paths` 测试固定 `resolveSessionsRoot` 的优先级与 `resolveDshHome` 的一致。`apps/cli` 测试钉住项目本地默认值与派生的 `session-query.db` 路径。无密钥 TUI 快照固定选择器的两个范围,包括范围行、逐行 workspace 行,以及页脚中的 Tab 提示。手动执行的一次跨 workspace 恢复在进程层面验证了替换后进程的工作目录变为目标 workspace。 +TUI 测试覆盖默认范围隐藏其他 workspace 但报告其数量、Tab 显示它们并带上逐行 workspace 标签、再按 Tab 返回时清空查询与选中项、按 workspace 标签搜索、无 cwd 的记录仍可见但不可选,以及交接同时收到 id 和在预检时重新读取到的 workspace。原先「拒绝已移动的 cwd」的用例现在断言交接携带新目录。构建后的 CLI PTY 测试会运行共享配置默认值与每进程派生的查询索引。无密钥 TUI 快照固定选择器的两个范围,包括范围行、逐行 workspace 行,以及页脚中的 Tab 提示。手动执行的一次跨 workspace 恢复在进程层面验证了替换后进程的工作目录变为目标 workspace。 diff --git a/apps/cli/config/web.cordis.yml b/apps/cli/config/web.cordis.yml index 30ab13e8b2..37f97c26b2 100644 --- a/apps/cli/config/web.cordis.yml +++ b/apps/cli/config/web.cordis.yml @@ -6,10 +6,14 @@ # # A patch replaces the targeted row's whole `config`, so each row below restates # every key it owns. `--dev` appends the dsh-client-hmr row in code -# (AppCLIEntry) — prod and dev differ by exactly that one row. +# (AppCLIEntry). # ── surface-specific values the base deliberately omits ───────────────────── +# TODO: Re-enable shared HMR for Web after its reload lifecycle is tested. +- id: hmr + disabled: true + - id: system-prompt config: persona: '' From a51143bded87ea9d70a60dcdaf4dd19db1d63530 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 14:56:39 +0800 Subject: [PATCH 71/77] fix(cli): close shared config review gaps --- ...cated-full-screen-tui-front-door.i18n.yaml | 4 +- ...17-dedicated-full-screen-tui-front-door.md | 2 +- ...dedicated-full-screen-tui-front-door.zh.md | 2 +- ...26-07-20-dsh-cli-personal-config.i18n.yaml | 4 +- .../2026-07-20-dsh-cli-personal-config.md | 4 +- .../2026-07-20-dsh-cli-personal-config.zh.md | 4 +- ...-20-remove-stdio-and-echo-agents.i18n.yaml | 4 +- ...2026-07-20-remove-stdio-and-echo-agents.md | 4 +- ...6-07-20-remove-stdio-and-echo-agents.zh.md | 4 +- ...7-29-shared-base-config-overlays.i18n.yaml | 4 +- .../2026-07-29-shared-base-config-overlays.md | 4 +- ...26-07-29-shared-base-config-overlays.zh.md | 4 +- ...root-and-derived-medium-recovery.i18n.yaml | 4 +- ...torage-root-and-derived-medium-recovery.md | 2 +- ...age-root-and-derived-medium-recovery.zh.md | 2 +- apps/cli/composition.md | 3 + apps/cli/config/base.cordis.yml | 10 ++- apps/cli/config/tui.cordis.yml | 8 +- apps/cli/config/web.cordis.yml | 10 +-- apps/cli/package.json | 1 + apps/cli/src/app-cli-entry.ts | 38 +-------- apps/cli/src/args.ts | 3 + apps/cli/src/tui.ts | 16 ++-- apps/cli/tests/args.spec.ts | 5 +- .../cordis-tutorial/01-first-plugin.i18n.yaml | 4 +- docs/cordis-tutorial/01-first-plugin.md | 2 +- docs/cordis-tutorial/01-first-plugin.zh.md | 2 +- examples/README.i18n.yaml | 4 +- examples/README.md | 8 +- examples/README.zh.md | 8 +- examples/acp-agent/README.i18n.yaml | 4 +- examples/acp-agent/README.md | 2 +- examples/acp-agent/README.zh.md | 2 +- examples/acp-agent/code-mode.cordis.yml | 2 +- examples/package.json | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 9 ++- packages/ui/app-boot/README.zh.md | 9 ++- packages/ui/app-boot/src/index.ts | 28 ++++++- packages/ui/app-boot/tests/app-boot.spec.ts | 29 ++++++- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 2 +- packages/ui/tui/README.zh.md | 2 +- packages/ui/tui/src/index.ts | 40 +++++++--- packages/ui/tui/tests/tui.spec.ts | 78 +++++++++++++++++++ pnpm-lock.yaml | 3 + scripts/demo-code-mode.mjs | 32 +++----- scripts/verify-cordis-config.ts | 14 ++-- 51 files changed, 277 insertions(+), 171 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml index 05355021ba..0691864d6e 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md -2026-07-17-dedicated-full-screen-tui-front-door.md: 47f5b7f94ce56d592c6a7897d8bcd470e12a0a60 -2026-07-17-dedicated-full-screen-tui-front-door.zh.md: bbbfaafd8c5fc382dad070daf1eba26a0c6dcedd +2026-07-17-dedicated-full-screen-tui-front-door.md: 10564b110cc2e56615bde86830c0d39d5a7cee38 +2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 4624a4db3db598793eee257f829a080f4d7ad711 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md index 47f5b7f94c..10564b110c 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md @@ -14,7 +14,7 @@ The interactive channel must remain a Cordis plugin over the same agent, session DeepSeek Harness ships [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) as a dedicated Cordis plugin. It owns terminal input and presentation only; agent lifecycle, session persistence, tool execution, and the model-facing question tool remain separate composition entries. The plugin requires both stdin and stdout to be TTYs and fails instead of silently changing to line-oriented behavior. -There is one terminal front door. `@deepseek-ai/dsh-tui` mounts before the configured agent, and `apps/cli/config/tui.cordis.yml` — an overlay over the shared `base.cordis.yml` — owns the interactive coding composition, with the Code Mode overlay in `examples/code-mode`. Non-interactive tasks use `@deepseek-ai/dsh-cli-demo`; ACP remains a separate automation protocol. +There is one terminal front door. `@deepseek-ai/dsh-tui` mounts before the configured agent, and `apps/cli/config/tui.cordis.yml` — an overlay over the shared `base.cordis.yml` — owns the interactive coding composition. Non-interactive tasks use the official headless surface; ACP remains a separate automation protocol and owns the supported Code Mode demo. The selected front door receives the exact generated or resumed `SessionId` used by the pre-created agent. It mounts before the agent composition, waits for the matching root agent, and enters full-screen mode only after that agent exists. A matching `agent-loop/config-start-failed` event is therefore reported before screen takeover and exits with status 1. diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md index bbbfaafd8c..4624a4db3d 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md @@ -14,7 +14,7 @@ Status: implemented DeepSeek Harness 将 [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 作为独立的 Cordis 插件交付。该插件只负责终端输入与呈现;agent 生命周期、会话持久化、工具执行以及模型可见的提问工具仍由不同组合项负责。插件要求 stdin 和 stdout 均为 TTY;条件不满足时会失败,不会静默切换为逐行输出。 -只有一个终端入口。`@deepseek-ai/dsh-tui` 在已配置 agent 之前挂载,而 `apps/cli/config/tui.cordis.yml`——叠加在共享 `base.cordis.yml` 之上的 overlay——拥有交互式 coding 组装,Code Mode overlay 则位于 `examples/code-mode`。非交互任务使用 `@deepseek-ai/dsh-cli-demo`;ACP 仍是独立的自动化协议。 +只有一个终端入口。`@deepseek-ai/dsh-tui` 在已配置 agent 之前挂载,而 `apps/cli/config/tui.cordis.yml`——叠加在共享 `base.cordis.yml` 之上的 overlay——拥有交互式 coding 组装。非交互任务使用官方 headless 界面;ACP 仍是独立的自动化协议,并拥有受支持的 Code Mode demo。 所选入口接收预创建 agent 使用的同一个新建或恢复 `SessionId`。入口先于 agent 组合挂载,等待相符的根 agent 出现,然后才进入全屏模式。因此,相符的 `agent-loop/config-start-failed` 事件会在接管屏幕前报告,并以状态码 1 退出。 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index 401d02aeac..11674d7747 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md -2026-07-20-dsh-cli-personal-config.md: cc965438214b68078647596af5a28fd666e7bd95 -2026-07-20-dsh-cli-personal-config.zh.md: 02f9c578061e1c25044c377c3ec8f80594275ae7 +2026-07-20-dsh-cli-personal-config.md: 3331e36c86002d91fb272868268707fed014d01a +2026-07-20-dsh-cli-personal-config.zh.md: 172d84b075ed7ecc127c317b47e30581db67f89a diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index cc96543821..3331e36c86 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -14,10 +14,10 @@ Two coupled pieces, aligned with the `apps/` assembly tier proposed by the `dsh **The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` joins the workspaces as the product-assembly tier over `packages/*` libraries. The bin's dispatch reserves `web` and `-p`/`--prompt` for PR #443 (they exit with a pointer) so the two branches merge as a near-union; everything else runs the default surface: the interactive TUI, booting the shipped `examples/tui-agent/cordis.yml` (or an explicit config argument) with the invoking directory as the workspace. The committed `bin/dsh` launcher resolves the checkout through its own real path and runs the bin **from source** through Node's native TypeScript transform plus the app-owned tsconfig-paths loader, so `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` installs a command that always executes the current working tree. `pnpm run demo:tui` runs the same entry. -**Personal config (`dsh-app-boot`).** The personal overlay lives in the Harness home — `$DSH_HOME`, else `~/.dsh` — resolved by the shared [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md) (`@deepseek-ai/dsh-paths`), the same single root skills and AGENTS.md resolve against. The dsh TUI surface consumes its two optional files; the demo bins boot their committed trees verbatim: +**Personal config (`dsh-app-boot`).** The personal overlay lives in the Harness home — `$DSH_HOME`, else `~/.dsh` — resolved by the shared [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md) (`@deepseek-ai/dsh-paths`), the same single root skills and AGENTS.md resolve against. The official dsh surfaces consume its two optional files; the demo bins boot their committed trees verbatim: - `.env` — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient > project `.env` > personal `.env`. -- `config.yaml` — a top-level YAML array of `@cordisjs/plugin-include` `PatchOptions`, parsed with the include's own `!!js` dialect (`loadPersonalPatches`) and passed to `boot()`, which forwards it as the root include's `patches`. Patch semantics are exactly the committed overlay semantics (the Code Mode overlay is the template): an id-targeted patch replaces the named entry's whole `config`, `insert` appends entries, an unmatched id warns and is skipped. +- `config.yaml` — a top-level YAML array of `@cordisjs/plugin-include` `PatchOptions`, parsed with the include's own `!!js` dialect (`loadPersonalPatches`) and passed to `boot()`, which forwards it as the root include's `patches`. Patch semantics match the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config`, `insert` appends entries, and an unmatched id is a silent no-op. - A missing file means no overlay; a present-but-unreadable, unparsable, or non-array file throws at boot (misconfiguration fails loud, never a silent skip). The PTY smoke's launcher isolates `$DSH_HOME` to a per-test directory, exactly as it already isolates `DSH_AGENTS_HOME`, so a developer's real personal overlay cannot leak into fixtures; only the dsh CLI reads personal config, so no other test launcher needed changes. diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index 02f9c57806..172d84b075 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -14,10 +14,10 @@ Status: implemented **`dsh` CLI(`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 作为 `packages/*` 库之上的产品装配层加入 workspaces。bin 的分发把 `web` 和 `-p`/`--prompt` 保留给 PR #443(它们以指引退出),使两个分支能以接近并集的方式合并;其余一切都运行默认表面:交互式 TUI,加载随仓库提供的 `examples/tui-agent/cordis.yml`(或显式的配置参数),并以调用目录为工作区。已提交的 `bin/dsh` 启动器通过自身真实路径解析 checkout,通过 Node 的原生 TypeScript 转换和应用自身持有的 tsconfig-paths loader **从源码**运行该 bin,因此 `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` 安装的命令永远执行当前工作树。`pnpm run demo:tui` 运行同一入口。 -**个人配置(`dsh-app-boot`)。** 个人 overlay 存放在 Harness home——`$DSH_HOME`,否则 `~/.dsh`——由共享的 [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md)(`@deepseek-ai/dsh-paths`)解析,与 skills、AGENTS.md 解析所依据的单一根目录相同。dsh 的 TUI 表面消费其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动: +**个人配置(`dsh-app-boot`)。** 个人 overlay 存放在 Harness home——`$DSH_HOME`,否则 `~/.dsh`——由共享的 [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md)(`@deepseek-ai/dsh-paths`)解析,与 skills、AGENTS.md 解析所依据的单一根目录相同。dsh 的官方界面消费其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动: - `.env`——在调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境变量 > 项目 `.env` > 个人 `.env`。 -- `config.yaml`——顶层 YAML 数组,元素为 `@cordisjs/plugin-include` 的 `PatchOptions`,用 include 自己的 `!!js` 方言解析(`loadPersonalPatches`)并传给 `boot()`,由它作为根 include 的 `patches` 转发。补丁语义与已提交 overlay 完全一致(Code Mode overlay 是模板):按 id 定位的补丁替换该配置项的整个 `config`,`insert` 追加配置项,未匹配的 id 记录警告并跳过。 +- `config.yaml`——顶层 YAML 数组,元素为 `@cordisjs/plugin-include` 的 `PatchOptions`,用 include 自己的 `!!js` 方言解析(`loadPersonalPatches`)并传给 `boot()`,由它作为根 include 的 `patches` 转发。补丁语义与交付的 surface overlay 一致:按 id 定位的补丁替换该配置项的整个 `config`,`insert` 追加配置项,未匹配的 id 静默不执行任何操作。 - 文件缺失即无 overlay;文件存在但不可读、不可解析或非数组则在启动时抛出(配置错误响亮失败,绝不静默跳过)。 PTY 冒烟测试的启动器把 `$DSH_HOME` 隔离到每个测试自己的目录,与它已有的 `DSH_AGENTS_HOME` 隔离方式完全一致,开发者真实的个人 overlay 不可能泄漏进 fixture;只有 dsh CLI 读取个人配置,因此其他测试启动器无需改动。 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml index c40031c21f..c059ba010c 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md -2026-07-20-remove-stdio-and-echo-agents.md: 72b8f5e0eb161acb7152d1b1dd4e455addf36dbf -2026-07-20-remove-stdio-and-echo-agents.zh.md: 95af2e74ccce4738fed713c3c58904c48517a479 +2026-07-20-remove-stdio-and-echo-agents.md: b578e49f004cabf0e37385ea06a769ced5388217 +2026-07-20-remove-stdio-and-echo-agents.zh.md: 3d8eecf73f17f3b62dc4b35b0f3c1af03f6f3a86 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md index 72b8f5e0eb..b578e49f00 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md @@ -18,7 +18,7 @@ The stdio and Echo agents are removed without compatibility packages, modes, com The remaining application roles are explicit: -- [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) owns terminal-interactive execution. It rejects non-TTY streams before Loader boot; `apps/cli/config/base.cordis.yml` plus the `tui.cordis.yml` overlay own the complete coding composition, with the Code Mode overlay in `examples/code-mode` and PTY plus terminal-snapshot coverage in `apps/cli/tests/`. +- [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) owns terminal-interactive execution. It rejects non-TTY streams before Loader boot; `apps/cli/config/base.cordis.yml` plus the `tui.cordis.yml` overlay own the complete coding composition, with PTY plus terminal-snapshot coverage in `apps/cli/tests/`. - [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) owns non-interactive execution, including pipes. `examples/headless-agent` owns the real-model one-shot composition, replay snapshots, generic real-agent suites, and test-only keyless Loader fixtures. - [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) and `@deepseek-ai/dsh-jsonrpc` own their framed protocol integrations. @@ -30,7 +30,7 @@ Keyless validation is test-owned. The Headless Loader smoke uses a fixture adapt TUI and Headless Loader coverage run the real app packages in source and built modes. PTY-driven subprocess coverage is reserved for the TUI lifecycle; other entry-point smokes use the one-shot pipe protocol. Headless proves its task/result and tool-call contracts. Generated graphs and repository searches reject stale package, command, leaf, SDK-interface, `createStdioChat`, and `StdioRuntime` references. -The TUI PTY smoke includes the Code Mode overlay composition, while `examples/cordis-agent/tests/keyless-smoke.e2e.ts` provides a minimal PTY boot over the real Cordis-agent Loader tree. The built `dsh` bin rejects a piped TUI launch before Loader boot and points at its one-shot `-p` mode; `apps/cli/tests/built-bin.e2e.ts` pins that path, while `cli-demo`'s built-bin suite runs text, JSON, and structurally parsed `stream-json` output under plain Node, persists fresh sessions, and rejects invalid arguments and missing config without contaminating stdout. Time-context integration uses the real Headless composition for two ordered turns, while its package tests own finer elapsed-time behavior. +The built `dsh` bin rejects a piped TUI launch before Loader boot and points at its one-shot `-p` mode; `apps/cli/tests/built-bin.e2e.ts` pins that path, while `cli-demo`'s built-bin suite runs text, JSON, and structurally parsed `stream-json` output under plain Node, persists fresh sessions, and rejects invalid arguments and missing config without contaminating stdout. Code Mode has programmatic TUI snapshots and an ACP overlay demo. Time-context integration uses the real Headless composition for two ordered turns, while its package tests own finer elapsed-time behavior. ## Alternatives considered diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md index 95af2e74cc..3d8eecf73f 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md @@ -18,7 +18,7 @@ DeepSeek Harness 在 TUI 和 Headless coding agent 之外,还提供了两个 保留的应用角色均有明确归属: -- [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 负责终端交互式执行。它会在 Loader 启动前拒绝非 TTY 流;`examples/tui-agent` 拥有完整 coding 组装、Code Mode 覆盖层、PTY 覆盖和终端快照。 +- [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 负责终端交互式执行。它会在 Loader 启动前拒绝非 TTY 流;`apps/cli/config/base.cordis.yml` 与 `tui.cordis.yml` overlay 拥有完整 coding 组装,PTY 与终端快照覆盖则位于 `apps/cli/tests/`。 - [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) 负责非交互式执行,包括管道方式。`examples/headless-agent` 拥有真实模型的单次任务组装、回放快照、通用真实 agent 测试套件,以及仅供测试使用的无密钥 Loader fixture。 - [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) 和 `@deepseek-ai/dsh-jsonrpc` 负责各自的分帧协议集成。 @@ -30,7 +30,7 @@ SDK 工程模型与 create/config 工作流将 `stdio` 运行接口选项替换 TUI 与 Headless 的 Loader 覆盖以源码和构建产物两种模式运行真实 app 包。由 PTY 驱动的子进程覆盖仅用于 TUI 生命周期;其他入口冒烟测试使用单次管道协议。Headless 验证任务/结果契约和工具调用契约。生成图谱与仓库搜索会拒绝陈旧的包、命令、叶节点、SDK 接口、`createStdioChat` 和 `StdioRuntime` 引用。 -TUI PTY 冒烟测试包含 Code Mode 覆盖层组装,而 `examples/cordis-agent/tests/keyless-smoke.e2e.ts` 会基于真实 Cordis-agent Loader 目录树执行最小 PTY 启动。构建后的 `dsh` 可执行文件会在 Loader 启动前拒绝通过管道启动 TUI,并指向其单次 `-p` 模式;`apps/cli/tests/built-bin.e2e.ts` 固定了该执行路径,而 `cli-demo` 的 built-bin 套件在普通 Node 下运行文本、JSON 和经过结构化解析的 `stream-json` 输出,持久化新建会话,并在不污染 stdout 的情况下拒绝无效参数和缺失配置。时间上下文集成通过真实 Headless 组装执行两个有序轮次,而更细粒度的耗时行为由时间上下文的包级测试负责。 +构建后的 `dsh` 可执行文件会在 Loader 启动前拒绝通过管道启动 TUI,并指向其单次 `-p` 模式;`apps/cli/tests/built-bin.e2e.ts` 固定了该执行路径,而 `cli-demo` 的 built-bin 套件在普通 Node 下运行文本、JSON 和经过结构化解析的 `stream-json` 输出,持久化新建会话,并在不污染 stdout 的情况下拒绝无效参数和缺失配置。Code Mode 由程序化 TUI 快照与 ACP overlay demo 覆盖。时间上下文集成通过真实 Headless 组装执行两个有序轮次,而更细粒度的耗时行为由时间上下文的包级测试负责。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml index 2337dc6e40..b100535da6 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md -2026-07-29-shared-base-config-overlays.md: 4b9b8de2f07bf1747ed7aa50d67740df768c7491 -2026-07-29-shared-base-config-overlays.zh.md: c0ccc5880909afd1dec0cbb387067a3d72c1ae16 +2026-07-29-shared-base-config-overlays.md: ee642cbc786bef708791fb58e655c5a3f0e9c4e7 +2026-07-29-shared-base-config-overlays.zh.md: b7fc9c6b121b8d0eb94d734af6bda6df45e25b1d diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md index 4b9b8de2f0..ee642cbc78 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md @@ -24,7 +24,7 @@ Precedence is list order, last write winning per row: base, then the surface ove A patch replaces its target row's whole `config` rather than merging, which shapes the split: a row whose value differs per surface lives in the overlays, never in the base, so no row is patched by three layers at once. Session identity therefore cannot ride a config key at all — it moved to `dsh-agent-loop`'s `CONFIGURED_AGENT_IDENTITIES_KEY`, as [the launcher-owned identity note](../architecture/2026-07-28-launcher-owned-resume-identity.md) now records. -`examples/tui-agent`, `examples/cordis-agent`, and `packages/examples/tui-demo` are deleted. The TUI tests move to `apps/cli/tests/`, the cordis-toolset e2e to `packages/cordis/tool-cordis/tests/`, and `examples/code-mode` survives as a genuine example: an overlay that patches `tools.mode` and inserts the code runtime. +`examples/tui-agent`, `examples/cordis-agent`, `examples/code-mode`, and `packages/examples/tui-demo` are deleted. The TUI tests move to `apps/cli/tests/`, the cordis-toolset e2e to `packages/cordis/tool-cordis/tests/`, and the supported Code Mode demo remains the ACP overlay at `examples/acp-agent/code-mode.cordis.yml`. ## Alternatives considered @@ -46,7 +46,7 @@ A patch whose `id` matches no row stays a no-op rather than an error. That is de ## Verification -Composition is checked by booting each tree through the real Loader and inspecting settled entries, not by reading YAML; both surfaces settle with zero unloaded rows, and Web starts its `httpServer` with sandboxed Bash and filesystem providers. The three-layer case (`base` + `tui` + `code-mode`) confirms `tools.mode` reaching `code` over the TUI overlay's `native`. +Composition is checked by booting each tree through the real Loader and inspecting settled entries, not by reading YAML; both surfaces settle with zero unloaded rows, and Web starts its `httpServer` with sandboxed Bash and filesystem providers. Code Mode remains covered by the ACP overlay and programmatic TUI snapshots rather than a separate shipped TUI application. All eight terminal snapshot scenarios replay byte-identically after moving, and the 14-case PTY smoke passes, including two cases that assert a personal overlay reaches an **inserted** row — the behavior the vendored `plugin-include` fix enables ([`vendor/README.md`](../../../../vendor/README.md) local modification 8, covered by `packages/ui/app-boot/tests/config-reload.spec.ts`). diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md index c0ccc58809..b7fc9c6b12 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md @@ -24,7 +24,7 @@ Status: implemented patch 会整体替换目标配置项的 `config` 而不合并,这决定了拆分方式:取值因 surface 而异的配置项住在 overlay 中,绝不住在 base 里,从而没有任何配置项会被三层同时 patch。因此会话身份根本不能经由配置键传递——它迁移到了 `dsh-agent-loop` 的 `CONFIGURED_AGENT_IDENTITIES_KEY`,如[启动器持有身份的 note](../architecture/2026-07-28-launcher-owned-resume-identity.md) 现在所记录。 -`examples/tui-agent`、`examples/cordis-agent` 与 `packages/examples/tui-demo` 均被删除。TUI 测试迁往 `apps/cli/tests/`,cordis 工具集的 e2e 迁入 `packages/cordis/tool-cordis/tests/`,而 `examples/code-mode` 作为一个名副其实的示例保留下来:一个 patch `tools.mode` 并 insert code runtime 的 overlay。 +`examples/tui-agent`、`examples/cordis-agent`、`examples/code-mode` 与 `packages/examples/tui-demo` 均被删除。TUI 测试迁往 `apps/cli/tests/`,cordis 工具集的 e2e 迁入 `packages/cordis/tool-cordis/tests/`,受支持的 Code Mode demo 则保留为 `examples/acp-agent/code-mode.cordis.yml` 中的 ACP overlay。 ## 备选方案 @@ -46,7 +46,7 @@ patch 会整体替换目标配置项的 `config` 而不合并,这决定了拆 ## 验证 -组合的正确性通过用真实 Loader 启动每棵树并检查已就绪的条目来核对,而不是靠阅读 YAML:两个界面都能稳定完成且没有未加载项;Web 会以沙箱化 Bash 与文件系统提供方启动 `httpServer`。三层叠加的情形(`base` + `tui` + `code-mode`)确认 `tools.mode` 越过 TUI overlay 的 `native` 达到了 `code`。 +组合的正确性通过用真实 Loader 启动每棵树并检查已就绪的条目来核对,而不是靠阅读 YAML:两个界面都能稳定完成且没有未加载项;Web 会以沙箱化 Bash 与文件系统提供方启动 `httpServer`。Code Mode 继续由 ACP overlay 与程序化 TUI 快照覆盖,而不再维护独立交付的 TUI 应用。 全部八个终端快照场景在迁移后逐字节重放一致,14 个用例的 PTY 冒烟测试全部通过,其中两个用例断言个人 overlay 能触达一个 **insert 进来的**配置项——这正是 vendored `plugin-include` 修复所启用的行为([`vendor/README.md`](../../../../vendor/README.md) 本地修改第 8 条,由 `packages/ui/app-boot/tests/config-reload.spec.ts` 覆盖)。 diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml index af41b392bd..9ce321434f 100644 --- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md -2026-07-28-storage-root-and-derived-medium-recovery.md: 06fa98b10dc5ac3164d8905e7005a42d9e99ae92 -2026-07-28-storage-root-and-derived-medium-recovery.zh.md: b7bd18ffdbfaf412d9a91940cf1770e273f5b847 +2026-07-28-storage-root-and-derived-medium-recovery.md: 3937b17502d0bf640625e73822b758b1862b5391 +2026-07-28-storage-root-and-derived-medium-recovery.zh.md: ff2f196a911986d23b7a102e6336f353c6aef278 diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md index 06fa98b10d..3937b17502 100644 --- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md @@ -8,7 +8,7 @@ English | [中文](2026-07-28-storage-root-and-derived-medium-recovery.zh.md) The persisted projection cache ([RFC](2026-07-27-session-projection-and-command-log.md), shipped as `dsh-session-projection-cache`) surfaced two gaps in the storage substrate it landed on. Both are properties of the domain-KV stack ([design](2026-07-24-domain-kv-storage-and-workspace.md)), not of the cache itself, and both bite the cache first because it is the first *derived* medium on that stack. -**Where the files actually live.** The shipped composition gives the json backend a relative root — `root: './.storages'` (apps/cli/cordis.yml) — and `AppCLIEntry.composePatches` patches only the session store's root to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`, profile-overridable via `persistenceRoot`); no equivalent patch or profile key exists for `storage-json`. `JsonStorageBackend` never resolves its root either — each unit open joins the still-relative path against whatever `process.cwd()` is at that moment (packages/storage/storage-json/src/index.ts) — the exact hazard the JSONL session backend resolves-once to prevent ("later process.cwd() changes cannot split one backend across roots", packages/session-persistence/session-persistence-jsonl/src/index.ts). Net effect: session logs are global across launch directories, but `workspace.json` and `session_projcache.json` land under `/.storages/`. Two launches from different directories share their sessions yet see different workspace registries and different projection caches — and the cache exists precisely to serve the cross-session cold listing, which now misses for every session last cached under another launch directory. +**Where the files actually live.** The shipped Web overlay gives the json backend a relative root — `root: './.storages'` (`apps/cli/config/web.cordis.yml`) — while the shared base defaults the session store to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`); no equivalent global root exists for `storage-json`. `JsonStorageBackend` never resolves its root either — each unit open joins the still-relative path against whatever `process.cwd()` is at that moment (packages/storage/storage-json/src/index.ts) — the exact hazard the JSONL session backend resolves-once to prevent ("later process.cwd() changes cannot split one backend across roots", packages/session-persistence/session-persistence-jsonl/src/index.ts). Net effect: session logs are global across launch directories, but `workspace.json` and `session_projcache.json` land under `/.storages/`. Two launches from different directories share their sessions yet see different workspace registries and different projection caches — and the cache exists precisely to serve the cross-session cold listing, which now misses for every session last cached under another launch directory. **How recovery works today.** Inside a healthy medium the cache is fully self-healing by design: a `stateVersion`-mismatched row is discarded and refolded, a log shrunk below a row's watermark is detected by the anchored restore floor and answered with one full re-read, and every background write is fail-soft. But at the *medium* level there is no recovery at all: a truncated, hand-edited, or version-bumped `session_projcache.json` fails `openJsonUnit` with `malformed-medium`/`version-mismatch` (packages/storage/storage-json/src/format.ts), a schema-drifted record fails domain open with `invalid-record` (packages/storage/storage-domain/src/index.ts), the rejection propagates through `SessionProjectionCache[Service.init]`, and under the CLI's fail-loud boot the assembly refuses to start. A file whose entire content is rebuildable from session logs can brick boot. This contradicts the cache package's own stated stance ("a stale or unreadable cache costs a longer tail replay, never a wrong value") and the cache domain spec's JSDoc ("version bumps discard the whole medium"), which today describes an aspiration, not the implementation. The same fail-loud path is *correct* for `workspace.json` — workspace records are authoritative, not derivable — so the missing concept is a per-domain declaration of authority, not a global behavior change. diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md index b7bd18ffdb..ff2f196a91 100644 --- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md @@ -8,7 +8,7 @@ Status: proposed 持久投影缓存([RFC](2026-07-27-session-projection-and-command-log.md),已作为 `dsh-session-projection-cache` 落地)暴露了它所依托的存储基座的两个缺口。二者都是 domain-KV 栈([设计](2026-07-24-domain-kv-storage-and-workspace.md))的属性而非缓存自身的问题,且都首先咬到缓存——因为它是这条栈上第一个*派生*介质。 -**文件到底存在哪。** 出厂组合给 json 后端的是相对根目录——`root: './.storages'`(apps/cli/cordis.yml)——而 `AppCLIEntry.composePatches` 只把会话存储的根 patch 到全局 harness home(`$DSH_HOME/sessions`,默认 `~/.dsh/sessions`,可经 profile 键 `persistenceRoot` 覆盖);`storage-json` 没有对应的 patch 也没有 profile 键。`JsonStorageBackend` 自己也从不 resolve 根——每次打开 unit 都把仍然相对的路径 join 到当时的 `process.cwd()` 上(packages/storage/storage-json/src/index.ts)——这正是 JSONL 会话后端用「构造时 resolve 一次」防住的那个隐患("later process.cwd() changes cannot split one backend across roots",packages/session-persistence/session-persistence-jsonl/src/index.ts)。净效果:会话日志跨启动目录全局共享,但 `workspace.json` 和 `session_projcache.json` 落在 `<启动目录>/.storages/` 下。从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份——而缓存存在的意义恰恰是跨会话冷列表,如今凡是上次在别的启动目录下缓存过的会话全部 miss。 +**文件到底存在哪。** 出厂 Web overlay 给 json 后端的是相对根目录——`root: './.storages'`(`apps/cli/config/web.cordis.yml`)——而共享 base 将会话存储默认为全局 harness home(`$DSH_HOME/sessions`,默认 `~/.dsh/sessions`);`storage-json` 没有对应的全局根目录。`JsonStorageBackend` 自己也从不 resolve 根——每次打开 unit 都把仍然相对的路径 join 到当时的 `process.cwd()` 上(packages/storage/storage-json/src/index.ts)——这正是 JSONL 会话后端用「构造时 resolve 一次」防住的那个隐患("later process.cwd() changes cannot split one backend across roots",packages/session-persistence/session-persistence-jsonl/src/index.ts)。净效果:会话日志跨启动目录全局共享,但 `workspace.json` 和 `session_projcache.json` 落在 `<启动目录>/.storages/` 下。从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份——而缓存存在的意义恰恰是跨会话冷列表,如今凡是上次在别的启动目录下缓存过的会话全部 miss。 **现在是怎么恢复的。** 在健康介质内部,缓存按设计完全自愈:`stateVersion` 不匹配的行被丢弃重折,日志缩短到行水位以下由带锚的 restore floor 检出并以一次全量重读回答,每次后台写都是 fail-soft。但在*介质*层面完全没有恢复:被截断、被手改或版本被 bump 的 `session_projcache.json` 会让 `openJsonUnit` 以 `malformed-medium`/`version-mismatch` 失败(packages/storage/storage-json/src/format.ts),schema 漂移的记录让域 open 以 `invalid-record` 失败(packages/storage/storage-domain/src/index.ts),拒绝一路穿过 `SessionProjectionCache[Service.init]`,在 CLI 的 fail-loud 启动下整个组装拒绝启动。一个内容完全可从会话日志重建的文件能把启动搞死。这与缓存包自己声明的立场("a stale or unreadable cache costs a longer tail replay, never a wrong value")和缓存域 spec 的 JSDoc("version bumps discard the whole medium")相矛盾——后者今天描述的是愿望而非实现。同一条 fail-loud 路径对 `workspace.json` 却是*正确*的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 1cf07c1d4b..7082329d6d 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -32,6 +32,8 @@ flowchart LR cfg --> plugin_tui_llm_pi_ai plugin_tui_session_persistence_jsonl["session-persistence-jsonl
@deepseek-ai/dsh-session-persistence-jsonl"] cfg --> plugin_tui_session_persistence_jsonl + plugin_tui_session_query_sqlite["session-query-sqlite
@deepseek-ai/dsh-session-query-sqlite"] + cfg --> plugin_tui_session_query_sqlite plugin_tui_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] cfg --> plugin_tui_subprocess plugin_tui_bash_local["bash-local
@deepseek-ai/dsh-bash-local"] @@ -114,6 +116,7 @@ flowchart LR | `llm-retry` | `@deepseek-ai/dsh-llm-retry` | | `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` | | `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` | +| `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` | | `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash-local` | `@deepseek-ai/dsh-bash-local` | | `tool-bash` | `@deepseek-ai/dsh-tool-bash` | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index f665c2ba6e..67c2b3b551 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -70,7 +70,15 @@ - id: session-persistence-jsonl name: '@deepseek-ai/dsh-session-persistence-jsonl' config: - root: !!js process.getBuiltinModule('node:path').join(process.env.DSH_HOME || process.getBuiltinModule('node:path').join(process.getBuiltinModule('node:os').homedir(), '.dsh'), 'sessions') + root: !!js >- + (() => { const path = process.getBuiltinModule('node:path'); const home = process.getBuiltinModule('node:os').homedir(); const configured = process.env.DSH_HOME; const selected = configured !== undefined && configured.trim().length > 0 ? configured : path.join(home, '.dsh'); const expanded = selected === '~' ? home : selected.startsWith('~/') || selected.startsWith('~\\') ? path.join(home, selected.slice(2)) : selected; return path.join(path.resolve(expanded), 'sessions') })() + +# TUI consumes this shared session capability. Its launcher supplies a unique +# process-local path; non-TUI surfaces disable the row in their overlay. +- id: session-query-sqlite + name: '@deepseek-ai/dsh-session-query-sqlite' + config: + path: !!js launcherSessionQueryPath ?? './.sessions/session-query.db' - id: subprocess name: '@deepseek-ai/dsh-subprocess-local' diff --git a/apps/cli/config/tui.cordis.yml b/apps/cli/config/tui.cordis.yml index b1ba86e6c7..d6099a5736 100644 --- a/apps/cli/config/tui.cordis.yml +++ b/apps/cli/config/tui.cordis.yml @@ -50,8 +50,7 @@ config: cwd: !!js process.cwd() -# The shipped TUI presents the native tool registry. `examples/code-mode` is the -# overlay that switches this row to the `run_code` transport. +# The shipped TUI presents the native tool registry. - id: tools config: mode: native @@ -78,11 +77,6 @@ # The derived query index behind `/resume`. The launcher provides a unique # process-local path because this SQLite backend has one writer owner; the # project-local fallback applies when no launcher sets the typed slot. - - id: session-query-sqlite - name: '@deepseek-ai/dsh-session-query-sqlite' - config: - path: !!js launcherSessionQueryPath ?? './.sessions/session-query.db' - - id: session-reference name: '@deepseek-ai/dsh-session-reference' diff --git a/apps/cli/config/web.cordis.yml b/apps/cli/config/web.cordis.yml index 37f97c26b2..3d7fe06baf 100644 --- a/apps/cli/config/web.cordis.yml +++ b/apps/cli/config/web.cordis.yml @@ -14,9 +14,9 @@ - id: hmr disabled: true -- id: system-prompt - config: - persona: '' +# Session query is a TUI capability; Web owns its own session presentation. +- id: session-query-sqlite + disabled: true - id: tools config: @@ -26,10 +26,6 @@ # once the web UI owns the choice per session. mode: !!js process.env.DSH_TOOLS_MODE -- id: agent-loop - config: - agents: [] - - id: llm-deepseek config: apiKey: !!js process.env.DEEPSEEK_API_KEY diff --git a/apps/cli/package.json b/apps/cli/package.json index 2348f7845a..e3d1976c52 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -71,6 +71,7 @@ "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 709da4fc60..1ba1c0fc1e 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -12,7 +12,6 @@ import { createRequire } from 'node:module' import { networkInterfaces } from 'node:os' import { join, resolve } from 'node:path' import { Context } from 'cordis' -import type { FiberState } from 'cordis' import type { PatchOptions } from '@cordisjs/plugin-include' import yaml from 'js-yaml' import { boot, installFailLoud, loadEnv, loadOverlayPatches, loadPersonalPatches } from '@deepseek-ai/dsh-app-boot' @@ -88,14 +87,6 @@ const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { }) const includeYamlSchema = yaml.JSON_SCHEMA.extend(jsExprType) -/** - * Value mirror of cordis's `FiberState` const enum members the sweep needs - * (a const enum has no runtime object to import; same rationale as the - * client-side mirror in dsh-client-web). - */ -const FIBER_ACTIVE = 2 as FiberState.ACTIVE -const FIBER_PENDING = 0 as FiberState.PENDING - /** Constructor facts for one dsh invocation over the shared composition (argv already parsed by the surface bin). */ export interface AppCLIEntryOptions { /** Absolute path of the shared base config the Loader includes. */ @@ -174,10 +165,8 @@ export class AppCLIEntry { } /** - * Compose the patch set from the non-yml config sources: computed - * engineering defaults (the global session root), profile json (user - * config, overriding those defaults), CLI flags, and the resolved frontend - * dist. Patches replace a row's config wholesale, so each patched row's yml + * Compose the patch set from profile json, CLI flags, and the resolved + * frontend dist. Patches replace a row's config wholesale, so each patched row's yml * static values are re-read here (bypass parse) and merged under the overrides. */ private composePatches(): void { @@ -189,7 +178,6 @@ export class AppCLIEntry { overrides.set(entryId, bag) } - // Source 1: profile json (missing file = empty; unmapped key = loud). for (const [key, value] of Object.entries(this.readProfile())) { const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key) @@ -215,7 +203,6 @@ export class AppCLIEntry { // user config. Workspace knowledge stays here. put('webserver', 'distIndex', this.resolveDistIndex()) - this.patches = [...overrides.entries()].map(([id, bag]) => { const yml = rows.get(id) if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`) @@ -241,28 +228,9 @@ export class AppCLIEntry { }) } - /** - * Shared boot catches import failures, installFailLoud catches late apply - * rejections, and the all-ACTIVE sweep - * below catches PENDING fibers (cordis inject waiting has no timeout). - */ + /** Install the diagnostic for plugin rejections that happen after settled boot. */ private assertBoot(): void { installFailLoud('dsh') - const failures: string[] = [] - for (const entry of this.ctx.loader.entries()) { - if (entry.fiber === undefined || entry.disabled) continue - const state = entry.fiber.state - if (state === FIBER_ACTIVE) continue - if (state === FIBER_PENDING) { - const missing = Object.keys(entry.fiber.inject).filter(service => this.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(state)}`) - } - } - if (failures.length > 0) { - throw new Error(`dsh: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`) - } } /** diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index f5600ff610..a1f0fff4c4 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -126,6 +126,9 @@ Examples: dsh --resume continue a past session `) .exitOverride() + // Stop parent options at a subcommand boundary so `web --config` belongs to + // Web while `--config ... web` remains a leaked default-surface option. + .enablePositionalOptions() // Default surface: option-only (no positional), so `web` can be a real // subcommand without a positional collision. .option('-p, --prompt ', 'answer this task without the interactive UI, then exit') diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index db10b2f06c..f13451c04e 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -10,8 +10,8 @@ * workspace, and an in-place resume enters the selected session's own directory. * `dsh meta` * ({@link runMeta}) is the one exception — it makes this harness checkout the - * workspace. `dsh upgrade` ({@link runSkillSession}) are fresh - * sessions whose first turn auto-invokes a bundled skill. After boot, the + * workspace. `dsh upgrade` ({@link runSkillSession}) is a fresh + * session whose first turn auto-invokes a bundled skill. After boot, the * agent's system prompt is told the path to this harness checkout so it can * find its own source. * @module @deepseek-ai/dsh/tui @@ -146,7 +146,7 @@ export async function runTui( const app: { current?: Context } = {} // Resume always enters the default surface because meta rejects parent // options, including `--resume`. The resumed session already persists its cwd. - const resumeArgs = (sessionId: string, _targetCwd?: string): string[] => [ + const resumeArgs = (sessionId: string): string[] => [ `--resume=${sessionId}`, // Both config flags must survive the handoff: resuming into a different // tree than the session was created in would silently change the agent. @@ -167,7 +167,7 @@ export async function runTui( process.execPath, ...process.execArgv, entry, - ...resumeArgs(sessionId, cwd), + ...resumeArgs(sessionId), ] // `execve` inherits the cwd, and the target session may belong to another // workspace. Enter it BEFORE teardown commits: an unreachable directory @@ -199,14 +199,14 @@ export async function runTui( const replaceTree = configReplace !== undefined const patches = replaceTree ? [] : [ ...loadOverlayPatches(NAME, TUI_OVERLAY), - ...config === undefined + ...resolvedConfig === undefined ? loadPersonalPatches(NAME) ?? [] - : loadOverlayPatches(NAME, resolveConfigPath(resolve(config), undefined)), + : loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)), ] const queryIndexPath = join(tmpdir(), SESSION_QUERY_DB) const ctx = await boot( NAME, - replaceTree ? resolveConfigPath(resolve(configReplace), undefined) : BASE_CONFIG, + resolvedConfigReplace === undefined ? BASE_CONFIG : resolveConfigPath(resolvedConfigReplace, undefined), patches, (hostCtx) => { // The launcher owns session identity and the exit line: a config-mounted @@ -230,7 +230,7 @@ export async function runTui( rm(`${queryIndexPath}-wal`, { force: true }), rm(`${queryIndexPath}-shm`, { force: true }), ]) - }, 'launcherSessionQueryPath.cleanup') + }, `${SESSION_QUERY_SQLITE_PATH_KEY}.cleanup`) if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost) // Seed the first turn only for a fresh session, so resuming never // re-invokes the skill. diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 6e7211694c..396a0e6ca5 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -31,10 +31,9 @@ describe('parseDshArgs', () => { expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) expect(parse(['meta'])).toEqual({ mode: 'meta' }) - // Credential setup is option-free: it writes the Harness-home .env, so - // there is nothing for a flag to select. // Bare `web` carries no host/port: the shipped Web overlay owns the default. expect(parse(['web'])).toEqual({ mode: 'web', dev: false }) + expect(parse(['web', '--config', 'web.yml'])).toEqual({ mode: 'web', dev: false, config: 'web.yml' }) // Host/port are unvalidated pass-throughs (the webserver schema gates them // at boot); the adapter only coerces the port string to a number. expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) @@ -72,7 +71,7 @@ describe('parseDshArgs', () => { expect(exitCode(['meta', '--config', 'c.yml'])).toBe(1) expect(exitCode(['meta', '--config-replace', 'tree.yml'])).toBe(1) expect(exitCode(['meta', '-p', 'task'])).toBe(1) - // `upgrade` take no options: any leaked default-surface flag is a + // `upgrade` takes no options: any leaked default-surface flag is a // mistyped invocation, not a silently-dropped input. expect(exitCode(['upgrade', '--resume', 's'])).toBe(1) expect(exitCode(['upgrade', '--config', 'c.yml'])).toBe(1) diff --git a/docs/cordis-tutorial/01-first-plugin.i18n.yaml b/docs/cordis-tutorial/01-first-plugin.i18n.yaml index d2e91da389..a1ee89f99e 100644 --- a/docs/cordis-tutorial/01-first-plugin.i18n.yaml +++ b/docs/cordis-tutorial/01-first-plugin.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/01-first-plugin.md -01-first-plugin.md: 0af2b8a292c2968c37e633d620192414479caafc -01-first-plugin.zh.md: cbea3ca2f7d875f481ef6a17749a5fac9774de1b +01-first-plugin.md: 39f140b91d4c9f8cc197df4d08bd2c4c168cc858 +01-first-plugin.zh.md: 0715cd525f980ab237e65baf6982192d2e0c286c diff --git a/docs/cordis-tutorial/01-first-plugin.md b/docs/cordis-tutorial/01-first-plugin.md index 0af2b8a292..39f140b91d 100644 --- a/docs/cordis-tutorial/01-first-plugin.md +++ b/docs/cordis-tutorial/01-first-plugin.md @@ -48,7 +48,7 @@ The process exits on its own once nothing is left running. What happened: 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](../../apps/cli/config/base.cordis.yml), for example, is a longer plugin composition. +There is no framework bootstrap code in your file: a plugin describes what it contributes, and `cordis.yml` composes the application. The [TUI agent](../../apps/cli/config/tui.cordis.yml), for example, is a longer plugin composition. ## The two other plugin shapes diff --git a/docs/cordis-tutorial/01-first-plugin.zh.md b/docs/cordis-tutorial/01-first-plugin.zh.md index cbea3ca2f7..0715cd525f 100644 --- a/docs/cordis-tutorial/01-first-plugin.zh.md +++ b/docs/cordis-tutorial/01-first-plugin.zh.md @@ -48,7 +48,7 @@ hello from my first plugin 2. Loader 读取 `cordis.yml`,解析 `./hello.ts`,然后将其作为子插件挂载。 3. Cordis 调用你的 `apply(ctx)`。 -你的文件中没有框架启动代码:插件描述自己的贡献,`cordis.yml` 则组合应用。例如,[TUI agent(智能体)](../../apps/cli/config/base.cordis.yml) 就是一个更长的插件组合。 +你的文件中没有框架启动代码:插件描述自己的贡献,`cordis.yml` 则组合应用。例如,[TUI agent(智能体)](../../apps/cli/config/tui.cordis.yml) 就是一个更长的插件组合。 ## 其他两种插件形态 diff --git a/examples/README.i18n.yaml b/examples/README.i18n.yaml index d7c82ac95e..1956f0fd5e 100644 --- a/examples/README.i18n.yaml +++ b/examples/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/README.md -README.md: 61aff168c656082d20c12416b62512ac17f445cb -README.zh.md: 50fcaae6ee2997055d3c97480f2e5bed0983fc5e +README.md: a502f34128da497586d593f64d0ce1c05f68a067 +README.zh.md: e3c111bb6a8f67899b6f434345baa3b47640b7ee diff --git a/examples/README.md b/examples/README.md index 61aff168c6..a502f34128 100644 --- a/examples/README.md +++ b/examples/README.md @@ -10,12 +10,6 @@ A non-interactive agent demo that accepts one positional task, runs one complete Run with: `pnpm run demo:headless "task"` (needs `DEEPSEEK_API_KEY`). See [headless-agent/README.md](headless-agent/README.md) for the output contract, safety boundaries, and snapshot suite. -## code-mode - -An **overlay** over the shipped TUI that reduces the model-facing registry to the `run_code` transport, so the model batches tool work into TypeScript programs instead of one call per turn. - -Run with: `pnpm run demo:code-mode` (needs `DEEPSEEK_API_KEY`). See [../apps/cli/README.md](../apps/cli/README.md). The interactive agent itself is not an example: `pnpm run demo:tui` boots [`apps/cli/config/tui.cordis.yml`](../apps/cli/config/tui.cordis.yml) over the shared base, and its PTY and snapshot scenarios live in `apps/cli/tests/`. - ## jsonrpc-agent An unattended coding agent driven through the Python SDK: JSON-RPC stdio, foreground-only `bash`, `read` / `write` / `edit`, one foreground `subagent`, `todo_write`, JSONL persistence, and compaction. It excludes terminal UI, stdout logging, approvals, skills, and background task controls. See [jsonrpc-agent/README.md](jsonrpc-agent/README.md). @@ -30,6 +24,6 @@ Run the browser UI at `http://127.0.0.1:3081` with `pnpm run demo:cordis`, or th An agent exposed as an **Agent Client Protocol (ACP)** automation server over JSON-RPC stdio, via [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo). Programmatic clients create fresh sessions, send text prompts, consume committed assistant text, answer one-shot permission requests, and cancel work. It owns the ACP keyless snapshot suite. -Run with: `pnpm run demo:acp` (needs `DEEPSEEK_API_KEY`); `pnpm run demo:code-mode acp` boots the same server in Code Mode via the `code-mode.cordis.yml` overlay. See [acp-agent/README.md](acp-agent/README.md) for the protocol and snapshot-test contracts. +Run with: `pnpm run demo:acp` (needs `DEEPSEEK_API_KEY`); `pnpm run demo:code-mode` boots the same server in Code Mode via the `code-mode.cordis.yml` overlay. See [acp-agent/README.md](acp-agent/README.md) for the protocol and snapshot-test contracts. The default `cordis.yml` composes [`@deepseek-ai/dsh-sandbox-local`](../packages/sandbox/sandbox-local), [`@deepseek-ai/dsh-bash-sandbox`](../packages/bash/bash-sandbox), and [`@deepseek-ai/dsh-user-approval`](../packages/ui/user-approval). `workspace-write` confines bash and filesystem mutations to each session workspace; a wider retry becomes a one-shot machine permission request over ACP. diff --git a/examples/README.zh.md b/examples/README.zh.md index 50fcaae6ee..e3c111bb6a 100644 --- a/examples/README.zh.md +++ b/examples/README.zh.md @@ -10,12 +10,6 @@ 运行:`pnpm run demo:headless "task"`(需要 `DEEPSEEK_API_KEY`)。输出契约、安全边界和快照套件详见 [headless-agent/README.md](headless-agent/README.md)。 -## code-mode - -叠加在交付 TUI 之上的 **overlay**:把面向模型的注册表收敛为 `run_code` 这一个传输,使模型把工具工作批量写进 TypeScript 程序,而不是每轮一次调用。 - -运行:`pnpm run demo:code-mode`(需要 `DEEPSEEK_API_KEY`)。详见 [../apps/cli/README.md](../apps/cli/README.md)。交互式 agent 本身不再是示例:`pnpm run demo:tui` 在共享 base 之上启动 [`apps/cli/config/tui.cordis.yml`](../apps/cli/config/tui.cordis.yml),其 PTY 与快照场景位于 `apps/cli/tests/`。 - ## jsonrpc-agent 通过 Python SDK 驱动的无人值守编码 agent:JSON-RPC stdio、仅前台 `bash`、`read`/`write`/`edit`、一个前台 `subagent`、`todo_write`、JSONL 持久化和压缩。它不包含终端 UI、stdout 日志、批准、skill 和后台任务控制。详见 [jsonrpc-agent/README.md](jsonrpc-agent/README.md)。 @@ -30,6 +24,6 @@ 作为 **Agent Client Protocol (ACP)** 自动化服务器通过 JSON-RPC stdio 公开的 agent,由 [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 提供。程序化客户端可以创建新会话、发送文本提示词、消费已提交的 assistant 文本、回答一次性权限请求并取消工作。它拥有 ACP 无密钥快照套件。 -运行:`pnpm run demo:acp`(需要 `DEEPSEEK_API_KEY`);`pnpm run demo:code-mode acp` 通过 `code-mode.cordis.yml` 覆盖以 Code Mode 启动同一服务器。协议与快照测试契约详见 [acp-agent/README.md](acp-agent/README.md)。 +运行:`pnpm run demo:acp`(需要 `DEEPSEEK_API_KEY`);`pnpm run demo:code-mode` 通过 `code-mode.cordis.yml` 覆盖以 Code Mode 启动同一服务器。协议与快照测试契约详见 [acp-agent/README.md](acp-agent/README.md)。 默认 `cordis.yml` 组合 [`@deepseek-ai/dsh-sandbox-local`](../packages/sandbox/sandbox-local)、[`@deepseek-ai/dsh-bash-sandbox`](../packages/bash/bash-sandbox) 和 [`@deepseek-ai/dsh-user-approval`](../packages/ui/user-approval)。`workspace-write` 将 bash 和文件系统变更限制在每个会话 workspace 中;范围更广的重试会通过 ACP 成为一次性机器权限请求。 diff --git a/examples/acp-agent/README.i18n.yaml b/examples/acp-agent/README.i18n.yaml index 3b208eb9b5..dc603691e1 100644 --- a/examples/acp-agent/README.i18n.yaml +++ b/examples/acp-agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/acp-agent/README.md -README.md: 0d63ec1f2d9165b9faf0817bd94fbe15b97fa961 -README.zh.md: 84482aad8352ab38527dcf4d9e1bfefc8d496c91 +README.md: a37954d4d52900413a506f227759a0a7dd2a25d0 +README.zh.md: a38431ea2f01fe16ff39b5dd00ec23e881713f5d diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index 0d63ec1f2d..a37954d4d5 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -6,7 +6,7 @@ Automation-oriented [Agent Client Protocol](https://agentclientprotocol.com) ser ```sh pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) -pnpm run demo:code-mode acp # same protocol with the Code Mode tool transport +pnpm run demo:code-mode # same protocol with the Code Mode tool transport ``` The leaf loads the ACP app, DeepSeek adapter, sandboxed bash and filesystem stacks, one-shot approval policy, compaction, subagents, workflows, hooks, a derived session-query index, and repeat guard. The app creates one fresh agent per `session/new`, persists sessions to JSONL, and keeps stdout protocol-pure. [`session-query.cordis.yml`](session-query.cordis.yml) explicitly opts into the workspace-authorized query tools and generic timeout/spill policies for their dedicated snapshot; [`fs.cordis.yml`](fs.cordis.yml) adds spill storage for filesystem scenarios, [`code-mode.cordis.yml`](code-mode.cordis.yml) adds `run_code` and its generated TypeScript SDK, and [`web.cordis.yml`](web.cordis.yml) adds the web seam, the local fetch provider, `web_fetch`, and a loopback HTML fixture server for the web-fetch snapshot. diff --git a/examples/acp-agent/README.zh.md b/examples/acp-agent/README.zh.md index 84482aad83..a38431ea2f 100644 --- a/examples/acp-agent/README.zh.md +++ b/examples/acp-agent/README.zh.md @@ -6,7 +6,7 @@ ```sh pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) -pnpm run demo:code-mode acp # same protocol with the Code Mode tool transport +pnpm run demo:code-mode # same protocol with the Code Mode tool transport ``` 该叶节点加载 ACP 应用、DeepSeek 适配器、受沙箱限制的 bash 与文件系统栈、一次性批准策略、压缩(compaction)、subagent、工作流、钩子、派生会话查询索引和重复守卫。应用为每次 `session/new` 创建一个新 agent,将会话持久化到 JSONL,并保持 stdout 只含协议内容。[`session-query.cordis.yml`](session-query.cordis.yml) 为其专用快照显式选用 workspace 授权的查询工具和通用超时/溢出策略;[`fs.cordis.yml`](fs.cordis.yml) 为文件系统场景添加溢出存储,[`code-mode.cordis.yml`](code-mode.cordis.yml) 添加 `run_code` 及其生成的 TypeScript SDK,[`web.cordis.yml`](web.cordis.yml) 则为 web-fetch 快照添加 web seam、本地抓取提供方、`web_fetch` 与一个回环 HTML fixture(测试前置数据)服务器。 diff --git a/examples/acp-agent/code-mode.cordis.yml b/examples/acp-agent/code-mode.cordis.yml index 1192b284c0..a6070d1262 100644 --- a/examples/acp-agent/code-mode.cordis.yml +++ b/examples/acp-agent/code-mode.cordis.yml @@ -1,6 +1,6 @@ # Code Mode adds `ctx.codeRuntime` and changes the registry to one wire tool, # `run_code`, plus its generated TypeScript SDK prompt. The app bin selects this -# overlay for `demo:code-mode acp` and snapshot recording, and selects the sibling +# overlay for `demo:code-mode` and snapshot recording, and selects the sibling # replay overlay for `DSH_SNAPSHOT=replay`. A config patch replaces the whole app # config, so unchanged base fields are restated below. - id: base diff --git a/examples/package.json b/examples/package.json index 1de7ab71c1..046029960d 100644 --- a/examples/package.json +++ b/examples/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.0.1", "type": "module", - "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports\u2192lib. Not a build target.", + "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports→lib. Not a build target.", "dependencies": { "@cordisjs/plugin-hmr": "workspace:*", "@cordisjs/plugin-include": "workspace:*", diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 231e2e8c8e..d2e0899e7c 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 1a62323a6b47a0d59dc697ada97144b3c523f64a -README.zh.md: b3be5537738c3071dd0fb0fbcf2953eba1804749 +README.md: 6182bba7b64e2692d08ccfd95cd3bc91e733cebd +README.zh.md: edc890ce9b2977a5ab2ec4763225bc08e29ab7ee diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 1a62323a6b..6182bba7b6 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -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/config/base.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`) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index b3be553773..edc890ce9b 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -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/config/base.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`) diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index d58dfc1575..ab36ca58a5 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md -README.md: 2ccf03a2b30a416334e3b8dbe806875019213a31 -README.zh.md: 15c13adad5064df5e882f55dbef367c146376c6d +README.md: 4d8c7de65515a251f227075c7baf041fc1b210c8 +README.zh.md: d9b69a2b102a60685524288f75edeaabb21802db diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 2ccf03a2b3..4d8c7de655 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -10,8 +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 (`prepare` is where a bin provides launcher-owned context slots a mounted app reads, such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount the Loader/include tree, await it, assert entries loaded, and return the root context | +| `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 | @@ -23,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. @@ -44,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. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index 15c13adad5..d9b69a2b10 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -10,8 +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?)` | 创建根上下文,在插件挂载前执行可选的宿主准备操作(`prepare` 正是 bin 提供由启动器拥有、供已挂载应用读取的上下文插槽之处,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载 Loader/include 树并等待其结算,断言所有条目均已加载,最后返回根上下文 | +| `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` 注册使用 | @@ -23,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(测试前置数据)中。 @@ -44,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 的子树。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 2c2a84c18b..25e6291bbf 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -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' @@ -192,6 +192,31 @@ 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 + +/** + * Reject enabled Loader entries whose fibers did not reach ACTIVE after settle. + * @param ctx - The settled application root. + * @param binName - Diagnostic prefix. + */ +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 * tree settles. Entry names load through the Loader's internal module loader @@ -231,6 +256,7 @@ export async function boot( }) await ctx.loader.await() assertEntriesLoaded(ctx, binName) + assertEntriesActive(ctx, binName) return ctx } diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index ba72902308..264ba82e65 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -5,7 +5,7 @@ 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, + addHarnessSourceSection, assertEntriesActive, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION, installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess, } from '../src/index.ts' @@ -212,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', () => { diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index 864f4ff0bf..b8596c3346 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/tui/README.md -README.md: 88c4501d87b7f24de1f5cc0d67f4c0e03ec49aa4 -README.zh.md: f03120e5a7820e2bcb572ab31b535211cf859c82 +README.md: d3ea5b41e8398c92853c6f96160129d7b1c475ed +README.zh.md: 9ee509abe2cf5bf6360b7c91d318b2b69745d224 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 88c4501d87..d3ea5b41e8 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -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 diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index f03120e5a7..9ee509abe2 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -75,7 +75,7 @@ Footer 将会话报告的用量汇总为 `↑`;任 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 困住进程。 ## 颜色 diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index cdfef9355c..bca49abae6 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -841,11 +841,9 @@ export function createTuiChat( palette, overlayManager, // Optional and independently mounted: read at each use so config row order - // cannot decide whether /resume works. - // The TUI and query provider are sibling Loader fibers. During a command - // callback Cordis may transiently mark the provider non-ACTIVE even though - // its init completed and its disposal is ordered after this consumer. - sessionQuery: () => ctx.get('sessionQuery', false), + // cannot decide whether /resume works. Strict lookup excludes a closing or + // closed provider rather than dispatching into a stale SQLite handle. + sessionQuery: () => ctx.get('sessionQuery'), ui, editor, appendNotice, @@ -1654,9 +1652,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') @@ -1676,9 +1700,7 @@ export function apply(ctx: Context, config: Config): void { initialSkill === undefined ? {} : { initialSkill }, ), { terminal: new ProcessTerminal(), - exit: (code) => { - void ctx.fiber.dispose().finally(() => { process.exit(code) }) - }, + exit: (code) => { disposeRootAndExit(ctx, code) }, ...resumeHost === undefined ? {} : { handoffResume: (sessionId, cwd) => resumeHost.handoff(sessionId, cwd) }, ...goodbyeMessage === undefined ? {} : { goodbyeMessage }, }) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index a6a33dd1ba..7c026af706 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -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,30 @@ describe('goodbye message and /resume', () => { await dispose(result) }) + it('treats a closed session-query provider as unavailable', async () => { + let queryCtx: Context | undefined + const result = await setup({ + cwd: '/workspace', + async configureContext(ctx) { + await ctx.plugin({ + apply(child: Context) { + queryCtx = child + child.provide('sessionQuery', { + listSessions: () => Promise.reject(new Error('closed database must not be called')), + } as never) + }, + }) + }, + }) + await queryCtx!.fiber.dispose() + result.terminal.send('/resume') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('session query is not mounted') + expect(result.terminal.output).not.toContain('closed database') + 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 +4987,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((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() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 293d33b0f2..481484e7bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -365,6 +365,9 @@ importers: '@deepseek-ai/dsh-tool-bash': specifier: workspace:^ version: link:../../packages/bash/tool-bash + '@deepseek-ai/dsh-tool-cordis': + specifier: workspace:^ + version: link:../../packages/cordis/tool-cordis '@deepseek-ai/dsh-tool-fs': specifier: workspace:^ version: link:../../packages/fs/tool-fs diff --git a/scripts/demo-code-mode.mjs b/scripts/demo-code-mode.mjs index 5a98fdb5e8..7ddbbae2cc 100644 --- a/scripts/demo-code-mode.mjs +++ b/scripts/demo-code-mode.mjs @@ -1,28 +1,16 @@ -/** - * Boot the TUI or ACP Code Mode overlay, defaulting to TUI. Each overlay - * includes its base example, selects Code Mode, and adds the worker runtime. - * All require a DeepSeek API key; unsupported arguments fail with usage. - */ +/** Boot the ACP Code Mode overlay. Requires a DeepSeek API key. */ import { spawn } from 'node:child_process' -// Each UI's node invocation matches its base demo script plus the overlay config. -const UIS = new Map([ - ['tui', [ - '--import', - 'tsx/esm', - 'apps/cli/src/bin.ts', - '--config', - 'examples/code-mode/cordis.yml', - ]], - ['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']], -]) - -const ui = process.argv[2] ?? 'tui' -const args = UIS.get(ui) -if (!args || process.argv.length > 3) { - console.error('usage: pnpm run demo:code-mode [tui|acp]') +if (process.argv.length > 2) { + console.error('usage: pnpm run demo:code-mode') process.exit(2) } -const child = spawn(process.execPath, args, { stdio: 'inherit' }) +const child = spawn(process.execPath, [ + '--import', + 'tsx', + 'packages/examples/acp-demo/src/bin.ts', + '--config', + 'examples/acp-agent/code-mode.cordis.yml', +], { stdio: 'inherit' }) child.on('exit', (code, signal) => { process.exit(signal !== null ? 1 : code ?? 1) }) diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index f434303686..1e0127a826 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -29,6 +29,9 @@ interface PluginReference { } const root = resolve(import.meta.dirname, '..') +// These example files are overlays consumed by the built dsh app, so their bare +// specifiers resolve from apps/cli rather than the examples workspace. +const appOverlayFiles = new Set(['examples/web-cordis/cordis.yml']) const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { kind: 'scalar', @@ -109,7 +112,7 @@ function validateExampleResolution(): string[] { const dependencies = exampleManifest.dependencies ?? {} const localPackages = localPackageDirectories() const rootReferences = rootProjectReferences() - const exampleReferences = pluginReferences.filter(reference => reference.file.startsWith('examples/')) + const exampleReferences = pluginReferences.filter(reference => reference.file.startsWith('examples/') && !appOverlayFiles.has(reference.file)) violations.push(...missingPluginDependencies(exampleReferences, dependencies, 'examples/package.json')) const requiredPackages = new Set(exampleReferences.map(reference => packageNameFromSpecifier(reference.name))) @@ -129,12 +132,9 @@ function validateExampleResolution(): string[] { function validateAppResolution(): string[] { const dependencies = readManifest('apps/cli/package.json').dependencies ?? {} - const shipped = new Set([ - 'apps/cli/config/base.cordis.yml', - 'apps/cli/config/tui.cordis.yml', - 'apps/cli/config/web.cordis.yml', - ]) - const references = pluginReferences.filter(reference => shipped.has(reference.file)) + const shipped = new Set(globSync('*.cordis.yml', { cwd: resolve(root, 'apps/cli/config') }) + .map(file => `apps/cli/config/${file}`)) + const references = pluginReferences.filter(reference => shipped.has(reference.file) || appOverlayFiles.has(reference.file)) return missingPluginDependencies(references, dependencies, 'apps/cli/package.json') } From d0bf9091f1d31732f2e5e4127e08bb1f04b909b1 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 15:28:58 +0800 Subject: [PATCH 72/77] fix(cli): preserve active query access --- .../tests/fixtures/telemetry-otel.cordis.yml | 3 +++ packages/ui/tui/src/index.ts | 18 +++++++++++++----- packages/ui/tui/tests/tui.spec.ts | 9 ++++++--- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml index 34e23b828e..d9cc454bfb 100644 --- a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml +++ b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml @@ -8,6 +8,9 @@ - id: telemetry-redact-rule name: './telemetry-redact-rule.ts' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index bca49abae6..7e7e7dec13 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -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, @@ -55,6 +55,7 @@ import { TuiExtensionServiceImpl, TuiOverlayManager, } from './extension/overlay-manager.ts' + import { parseTuiPromptTemplate, renderTuiPromptTemplate, @@ -170,6 +171,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. */ @@ -840,10 +844,14 @@ export function createTuiChat( resolved, palette, overlayManager, - // Optional and independently mounted: read at each use so config row order - // cannot decide whether /resume works. Strict lookup excludes a closing or - // closed provider rather than dispatching into a stale SQLite handle. - sessionQuery: () => ctx.get('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, diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 3b2050f92e..942f059162 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context, type FiberState } from 'cordis' import { CombinedAutocompleteProvider, visibleWidth, type Terminal } from '@earendil-works/pi-tui' import AgentRegistry, { agentEvents, assembleContextFor, InboxItemId, type Agent, type InboxItem, @@ -503,7 +503,7 @@ describe('goodbye message and /resume', () => { await dispose(result) }) - it('treats a closed session-query provider as unavailable', async () => { + it('treats a terminal-state session-query provider as unavailable', async () => { let queryCtx: Context | undefined const result = await setup({ cwd: '/workspace', @@ -518,12 +518,15 @@ describe('goodbye message and /resume', () => { }) }, }) - await queryCtx!.fiber.dispose() + if (queryCtx === undefined) throw new Error('query provider did not mount') + const activeState = queryCtx.fiber.state + 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(result.terminal.output).not.toContain('closed database') + queryCtx.fiber.state = activeState await dispose(result) }) From 0b1aaa4b120ec152d88b22e6b1f1db703d9d71c8 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 15:30:59 +0800 Subject: [PATCH 73/77] test(tui): remove erased enum import --- packages/ui/tui/tests/tui.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 942f059162..a5b363c783 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { Context, type FiberState } from 'cordis' +import { Context } from 'cordis' import { CombinedAutocompleteProvider, visibleWidth, type Terminal } from '@earendil-works/pi-tui' import AgentRegistry, { agentEvents, assembleContextFor, InboxItemId, type Agent, type InboxItem, From 42ba71b4f2ba608886d0cf459e800ede416d5705 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 15:37:03 +0800 Subject: [PATCH 74/77] test(tui): pin transient query access --- packages/ui/tui/tests/tui.spec.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index a5b363c783..d99780dedb 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -503,8 +503,9 @@ describe('goodbye message and /resume', () => { await dispose(result) }) - it('treats a terminal-state session-query provider as unavailable', async () => { + 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) { @@ -512,7 +513,7 @@ describe('goodbye message and /resume', () => { apply(child: Context) { queryCtx = child child.provide('sessionQuery', { - listSessions: () => Promise.reject(new Error('closed database must not be called')), + listSessions: async () => { listCalls++; return [] }, } as never) }, }) @@ -520,12 +521,19 @@ describe('goodbye message and /resume', () => { }) 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(result.terminal.output).not.toContain('closed database') + expect(listCalls).toBe(1) queryCtx.fiber.state = activeState await dispose(result) }) From 9c5a5155016c8a79a0c58593a48ed0795c9fb47b Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 16:52:14 +0800 Subject: [PATCH 75/77] fix(example): drop the never-activated bash stack from the inheritance fixture dsh-bash-local injects `subprocess`, which this tree never mounted, so the bash chain sat PENDING and the bundle's tool-bash waited with it. The shared boot() all-ACTIVE assertion now surfaces that as a load failure. The scenario probes filesystem confinement only, so the rows are removed and the bundle opts out with `toolBash: false`. The recorded transcript is unchanged because bash never reached the model. --- .../subagent-inheritance.cordis.snapshot.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/examples/headless-agent/subagent-inheritance.cordis.snapshot.yml b/examples/headless-agent/subagent-inheritance.cordis.snapshot.yml index fb614a5372..554fb620fd 100644 --- a/examples/headless-agent/subagent-inheritance.cordis.snapshot.yml +++ b/examples/headless-agent/subagent-inheritance.cordis.snapshot.yml @@ -16,11 +16,6 @@ - id: replay name: '@deepseek-ai/dsh-llm-replay' -- id: bash - name: '@deepseek-ai/dsh-bash-local' - config: - timeoutMs: 60000 - # The confining filesystem stack: the wide deployment default lives on the # shared policy home; the seeded parent's read-only override must beat it # INSIDE the child for the scenario to deny. @@ -39,6 +34,9 @@ - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' +# This scenario probes filesystem confinement only, so the bash stack is absent: +# without it the bundle's `toolBash: false` is required, because `tool-bash` would +# otherwise wait forever for a `bash` executor this tree never mounts. - id: agent name: '@deepseek-ai/dsh-agent-spine-demo' config: @@ -46,6 +44,7 @@ workspaceContext: false skills: enabled: false + toolBash: false toolTasks: false goals: false From d8f4a0efdaf0007a24f191ef92890d8140977bb9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:17:38 +0800 Subject: [PATCH 76/77] chore(docs): refresh merged generated records --- docs/cordis-catalog/services.md | 4 ++-- packages/host/apiproxy/README.i18n.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 8e4030c2fe..ee713636ac 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -44,7 +44,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise Date: Thu, 30 Jul 2026 17:17:49 +0800 Subject: [PATCH 77/77] chore(docs): re-record generated artifacts after the master merge --- docs/cordis-catalog/services.md | 4 ++-- packages/host/apiproxy/README.i18n.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 8e4030c2fe..ee713636ac 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -44,7 +44,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise