From 568866d5a8d28223ba266ddeb9d6d7792be9aad5 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Tue, 28 Jul 2026 17:20:20 +0800 Subject: [PATCH 01/23] fix(web): orient agents to the running GUI --- ...-07-28-web-agent-runtime-context.i18n.yaml | 6 ++++ .../2026-07-28-web-agent-runtime-context.md | 33 +++++++++++++++++++ ...2026-07-28-web-agent-runtime-context.zh.md | 33 +++++++++++++++++++ apps/cli/README.i18n.yaml | 6 ++-- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/cordis.yml | 3 +- apps/cli/src/web.ts | 24 ++++++++++++++ apps/web/tests/replay-round-trip.e2e.ts | 22 +++++++++++-- apps/web/tests/scaffold.ts | 2 ++ .../system-prompt.expected.md | 7 ++++ 11 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.zh.md create mode 100644 apps/web/tests/snapshots/fresh-round-trip/system-prompt.expected.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.i18n.yaml new file mode 100644 index 0000000000..fdf6fbca4e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-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/bug-fix/2026-07-28-web-agent-runtime-context.md +2026-07-28-web-agent-runtime-context.md: 7e2e0beb4057db2533d8360e39b46199900f067b +2026-07-28-web-agent-runtime-context.zh.md: add98653d24b7bde84982ca23bb60cdb234f98d0 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.md new file mode 100644 index 0000000000..7e2e0beb40 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.md @@ -0,0 +1,33 @@ +# Agent Note: Web agents receive explicit runtime context + +Status: implemented + +English | [中文](2026-07-28-web-agent-runtime-context.zh.md) + +## Problem + +The Web composition configured an empty deployment persona and added no launcher-owned source or interaction-surface section. A session header recorded its working directory for tools and persistence, but the model prompt did not state that directory or identify the DeepSeek Harness Web GUI. A request such as “change this page's theme” therefore made the agent search the selected project for an unspecified page, even when the user meant the GUI running the session. + +## Decision + +The shared Web/headless composition supplies a concise coding-agent persona containing the resolved `{{model}}` and session `{{cwd}}`. `dsh web` additionally resolves the harness checkout from the launcher's module URL, installs the existing `harness:source` section, and adds an `app:web-surface` section before serving requests. + +The Web section treats unqualified references to “this page,” “this GUI,” or “this app” as references to the DeepSeek Harness Web GUI. It also states that the browser provides no implicit DOM, route, or screenshot context, so the model can identify the product without claiming visual state it did not receive. The assembled text is logged in `request/header`, preserving the model-visible/logged invariant. + +## Verification + +The keyless fresh-round-trip Web scenario boots the shipped composition, installs the same launcher context as `dsh web`, runs a real session through the HTTP/SSE application, and snapshots the first four system-prompt sections with source and working-directory paths normalized. The snapshot pins the harness identity, source checkout, Web orientation, and resolved coding-agent persona in request order. + +## Alternatives considered + +**Send URL, DOM, or screenshots with every prompt.** The observed failure needed stable product orientation, while the current root URL does not identify a selected component and no visual capture exists in the message contract. Adding dynamic page state would require a separate logged model-input design and is not implied by this fix. + +**Require the session Workspace to be the harness checkout.** Workspace cwd is the user's task target and may legitimately be an empty project or another repository. Conflating it with the application's source location would break that boundary and leave installed or externally launched sessions ambiguous. + +**Put Web wording in the global harness identity.** `dsh-system-prompt` serves TUI, ACP, SDK, and custom deployments that do not run in a browser. The composing Web app owns this surface fact. + +**Change the existing source-location section for every CLI surface.** The source section is shared with TUI and states only the checkout fact. Keeping Web orientation separate preserves that reusable contract and avoids telling headless or terminal agents that they are in a browser. + +## Consequences + +Web requests gain a short stable prompt prefix and may invalidate provider prefix caches once when this change is deployed. Agents can distinguish the GUI source checkout from the selected Workspace and resolve ordinary references to the current app without a clarification round trip. References to a specific visual state remain bounded by the explicit no-DOM/no-route/no-screenshot statement and may still require a path, description, or attachment. diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.zh.md new file mode 100644 index 0000000000..add98653d2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Web agent 获得显式运行时上下文 + +Status: implemented + +[English](2026-07-28-web-agent-runtime-context.md) | 中文 + +## 问题 + +Web 组合把部署 persona 配置为空,也没有添加由启动器拥有的源码位置或交互界面提示词段。会话 header 会记录工作目录,供工具与持久化使用,但模型提示词既不说明该目录,也不标识 DeepSeek Harness Web GUI。因此,当用户提出「修改这个页面的主题」之类的请求时,即使用户指的是承载当前会话的 GUI,agent 也只能在所选项目中搜索一个未明确说明的页面。 + +## 决策 + +Web/无头共享组合提供一段简洁的编码 agent persona,其中包含解析后的 `{{model}}` 与会话 `{{cwd}}`。`dsh web` 还会根据启动器模块的 URL 解析 harness checkout,安装现有的 `harness:source` 提示词段,并在对外提供请求服务前添加 `app:web-surface` 提示词段。 + +Web 提示词段把未限定的「这个页面」「这个 GUI」或「这个应用」解释为 DeepSeek Harness Web GUI。同时,它会明确说明浏览器不会隐式提供 DOM、路由或截图上下文,使模型能够识别产品,但不会声称掌握未收到的视觉状态。组装后的文本会记录在 `request/header` 中,从而保持「模型可见内容必须有日志记录」这一不变量。 + +## 验证 + +无密钥的 Web fresh-round-trip 场景会启动已交付组合,安装与 `dsh web` 相同的启动器上下文,并通过 HTTP/SSE 应用运行一个真实会话。测试会把源码路径和工作目录规范化,然后对系统提示词的前四个段落生成快照。该快照按请求顺序固定 harness 身份、源码 checkout、Web 界面定位,以及解析后的编码 agent persona。 + +## 考虑过的替代方案 + +**每次提示词都发送 URL、DOM 或截图。** 本次故障只需要稳定的产品定位;当前根 URL 无法标识所选组件,消息契约中也不存在视觉捕获内容。添加动态页面状态需要另行设计可记录的模型输入,不属于本次修复的隐含范围。 + +**要求会话 Workspace 必须是 harness checkout。** Workspace cwd 是用户任务的目标,可以合理地指向空项目或其他仓库。将其与应用源码位置混为一谈会破坏这一边界,并且仍无法消除已安装版本或外部启动会话中的歧义。 + +**把 Web 文案放入全局 harness 身份。** `dsh-system-prompt` 还服务于 TUI、ACP、SDK 和不在浏览器中运行的自定义部署。该界面事实应由组装 Web 应用负责。 + +**为所有 CLI 界面修改现有源码位置提示词段。** TUI 也复用源码位置提示词段,而该段只陈述 checkout 事实。单独保留 Web 界面定位可以维持这份可复用契约,避免错误地告诉无头或终端 agent 它们正处于浏览器中。 + +## 影响 + +Web 请求会增加一段较短且稳定的提示词前缀;部署此变更时,模型提供方的前缀缓存可能失效一次。agent 可以区分 GUI 源码 checkout 与所选 Workspace,并且无需再经过一轮澄清即可解析对当前应用的一般指代。对特定视觉状态的指代仍受「无 DOM/无路由/无截图」这一显式边界约束,必要时仍需用户提供路径、描述或附件。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index abe51abc2f..d1c6290a08 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.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 -README.md: 42d2a9641cf5d497c9aae45d9f60fce4498addb9 -README.zh.md: 0a62f8bb72e2cf2dbe045d28b81768bf4df800de +# pnpm run verify-translation-pairing --write apps/cli/README.md +README.md: 87cf1597fbb1ce91a7d61f912c42b0d6a68b2eea +README.zh.md: b3743f57a4dafda71e437a04ee0148b32dba2dca diff --git a/apps/cli/README.md b/apps/cli/README.md index 42d2a9641c..87cf1597fb 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -14,7 +14,7 @@ The TUI surface: - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. -The Web 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 one shared composition (`cordis.yml`): both tell the coding agent its model and session working directory, 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. Web additionally names the DeepSeek Harness Web GUI as the interaction surface and this checkout as its own source location; references such as “this page” therefore identify the GUI without claiming access to implicit DOM, route, or screenshot state. 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`). `DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode). diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 0a62f8bb72..b3743f57a4 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -14,7 +14,7 @@ TUI 界面: - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 -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 和无头界面启动同一个共享组合(`cordis.yml`):两者都会告知编码 agent 所用模型和会话工作目录,将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。Web 还会明确说明交互界面是 DeepSeek Harness Web GUI,并把当前 checkout 标记为自身源码位置;因此,「这个页面」等表述会指向该 GUI,但 agent 不会声称可以访问未显式提供的 DOM、路由或截图状态。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 `DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 5397c08746..b54200247b 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -40,7 +40,8 @@ - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' config: - persona: '' + persona: >- + You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}. - id: tools name: '@deepseek-ai/dsh-tools' diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 31282c8f5f..599d788d89 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -8,9 +8,32 @@ import { networkInterfaces } from 'node:os' import { fileURLToPath } from 'node:url' +import type { Context } from 'cordis' +import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' +import type {} from '@deepseek-ai/dsh-system-prompt' import { AppCLIEntry } from './app-cli-entry.ts' const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) +const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) + +/** Stable model-visible orientation for sessions created through `dsh web`. */ +export const WEB_SURFACE_PROMPT = 'You are interacting with the user through the DeepSeek Harness Web GUI. ' + + 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. ' + + 'The browser provides no implicit DOM, route, or screenshot context.' + +/** + * Add the launcher-owned source location and Web-surface orientation after the + * shared config tree settles. The request header logs both sections with every + * model-visible prompt. + * @param ctx - settled Web application context. + * @param sourceRoot - absolute checkout root resolved from the launcher module. + */ +export function installWebPromptContext(ctx: Context, sourceRoot: string): void { + const systemPrompt = ctx.get('systemPrompt') + if (systemPrompt === undefined) throw new Error('dsh web: systemPrompt service missing after settled boot') + addHarnessSourceSection(ctx, sourceRoot) + systemPrompt.section({ name: 'app:web-surface', order: -98, text: WEB_SURFACE_PROMPT }) +} // Display-only mirrors of the webserver schema's allowed hosts: the loopback // address the local URL always prints, and the all-interfaces value that gates @@ -40,6 +63,7 @@ export async function runWeb( ...workspaceRoot !== undefined && { workspaceRoot }, }) const { ctx, port: boundPort } = await entry.run() + installWebPromptContext(ctx, SOURCE_ROOT) let exiting = false const shutdown = (code: number): void => { diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index f4cf960cda..222bb28580 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -9,20 +9,22 @@ // Record: DSH_SNAPSHOT=record rewrites session.jsonl, then a keyless // DSH_SNAPSHOT=refresh regenerates ui.expected.md. import { readFile } from 'node:fs/promises' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, REPO_ROOT, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/fresh-round-trip', import.meta.url)) const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('./snapshots/fresh-round-trip/ui.expected.md', import.meta.url)) +const SYSTEM_PROMPT_EXPECTED = fileURLToPath(new URL('./snapshots/fresh-round-trip/system-prompt.expected.md', import.meta.url)) const MODE = webSnapshotMode() // The scenario's one drive prompt. Record sends it; replay asserts the @@ -35,6 +37,7 @@ describe('web e2e: fresh round trip through the real assembly', () => { let browser: Browser let page: Page let tripwire: ReturnType + let settledSessionId: SessionId | undefined const sessionEvents: SessionEvent[] = [] beforeAll(async () => { @@ -69,11 +72,24 @@ describe('web e2e: fresh round trip through the real assembly', () => { await input.fill(PROMPT) await input.press('Enter') const sessionId = await settled + settledSessionId = sessionId if (MODE === 'record') { await recordFixture(scaffold, sessionId, FIXTURE) } }, 200_000) + it('records the Web surface, source checkout, and session cwd in the request header', async () => { + if (settledSessionId === undefined) throw new Error('the drive turn did not publish a session id') + const agent = scaffold.ctx.agents.get(settledSessionId) + if (agent === undefined) throw new Error(`the settled Web agent ${settledSessionId} is no longer live`) + const system = agent.session.requestHeader()?.system + if (system === undefined) throw new Error('the settled Web request has no system prompt') + const prefix = system.split('\n\n').slice(0, 4).join('\n\n') + .split(REPO_ROOT).join('{{sourceRoot}}') + .split(join(scaffold.workspaceCwd, 'workspace')).join('{{cwd}}') + await compareOrRefreshGolden(SYSTEM_PROMPT_EXPECTED, prefix, MODE) + }) + it.skipIf(MODE === 'record')('rendered the settled turn: markdown, tool row, composer restore', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-settled')) // Browser settled-poll after host completion (host strictly precedes render). @@ -129,6 +145,6 @@ describe('web e2e: fresh round trip through the real assembly', () => { it.skipIf(MODE === 'record')('stayed clean: no pageerrors, no reconnect self-healing, no server errors', async () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'system-prompt.expected.md', 'ui.expected.md']) }) }) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index f722ead61b..ac360f38f2 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -44,6 +44,7 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' // Empty type imports carry the httpServer/agents/sessionPersistence Context merges. import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-agent' +import { installWebPromptContext } from '../../cli/src/web.ts' import { DIST_INDEX, REPO_ROOT, requireDist } from './support.ts' /** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the ACP/TUI suites). */ @@ -202,6 +203,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise Date: Tue, 28 Jul 2026 17:31:48 +0800 Subject: [PATCH 02/23] fix(web): keep surface prompt module-private --- apps/cli/src/web.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 599d788d89..67da6c73e8 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -17,7 +17,7 @@ const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) /** Stable model-visible orientation for sessions created through `dsh web`. */ -export const WEB_SURFACE_PROMPT = 'You are interacting with the user through the DeepSeek Harness Web GUI. ' +const WEB_SURFACE_PROMPT = 'You are interacting with the user through the DeepSeek Harness Web GUI. ' + 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. ' + 'The browser provides no implicit DOM, route, or screenshot context.' From 544d543ad168312353457e785bf4851df4aed4fc Mon Sep 17 00:00:00 2001 From: NI0317 Date: Tue, 28 Jul 2026 18:04:21 +0800 Subject: [PATCH 03/23] fix(web): close the GUI update feedback loop --- ...2026-07-28-web-gui-feedback-loop.i18n.yaml | 6 +++ .../2026-07-28-web-gui-feedback-loop.md | 37 +++++++++++++++++ .../2026-07-28-web-gui-feedback-loop.zh.md | 37 +++++++++++++++++ apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/src/web.ts | 41 ++++++++++++++----- apps/cli/tsconfig.json | 3 ++ apps/web/package.json | 2 +- apps/web/tests/replay-round-trip.e2e.ts | 21 ++++++++++ apps/web/tests/scaffold.ts | 2 +- .../system-prompt.expected.md | 2 +- apps/web/tests/vite-entry.e2e.ts | 29 +++++++++++++ apps/web/vite.config.ts | 17 +++++++- .../host/webserver/tests/webserver.spec.ts | 2 + 15 files changed, 188 insertions(+), 19 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.zh.md create mode 100644 apps/web/tests/vite-entry.e2e.ts diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.i18n.yaml new file mode 100644 index 0000000000..22390d728f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.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/bug-fix/2026-07-28-web-gui-feedback-loop.md +2026-07-28-web-gui-feedback-loop.md: 27295704d7b0cde3a46a6a28891545bfe31ed275 +2026-07-28-web-gui-feedback-loop.zh.md: 06367b09bd889bfb7a4052c46369720c4ec6d358 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md new file mode 100644 index 0000000000..27295704d7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md @@ -0,0 +1,37 @@ +# Agent Note: Web GUI changes close the loop on the existing URL + +Status: implemented + +English | [中文](2026-07-28-web-gui-feedback-loop.zh.md) + +## Problem + +The Web agent could identify neither the GUI hosting its session nor the URL the user was viewing. The [runtime-context decision](2026-07-28-web-agent-runtime-context.md) supplies the first fact, but a GUI edit still had no executable acceptance target: source edits, artifact builds, a listening process, and the user's existing page were unrelated observations. Repository affordances made a wrong substitute look valid because `apps/web/package.json` exposed `vite` as its `dev` script and bare Vite returned HTTP 200 even though it could not inject `window.__DSH_BOOT__`. + +The incident session recorded three consecutive failures. After changing the theme, turn 2 delegated acceptance to the user with `pnpm run demo:tui` or an unspecified browser application and ran no assembled Web check. Turn 3 read the frontend package script, launched bare Vite on port 5173, treated HTTP 200 as readiness, and reported success; the user instead received the expected missing-`__DSH_BOOT__` white screen. Turn 4 found `dsh web`, rebuilt the shell, started an unmanaged shell-background process on port 3334, and checked only that the new page returned 200 with a boot manifest. It never probed the existing port 3081. In fact, the port-3081 process predated the build, and its static host read the rebuilt dist on the next request, so refreshing the original page already showed the change. Only after the user reported that fact did turn 5 inspect port 3081 and remove the redundant server. + +## Decision + +`dsh web` publishes one canonical loopback URL as both model-visible orientation and a managed shell fact. The `app:web-surface` prompt section says that unqualified references identify this GUI, names the URL, and defines acceptance as rebuilding the affected Web artifacts and verifying that existing URL after refresh. `DSH_WEB_URL` carries the same value into every foreground or managed background bash call, so the agent can query the target without parsing prose or process listings. The section preserves the no-implicit-DOM, route, or screenshot boundary and does not claim that a LAN alias equals the browser's literal address. + +The `apps/web` development script and Vite configuration reject serve mode before opening a port. Their diagnostics identify `apps/web` as a build-only shell, explain that only `dsh web` injects `window.__DSH_BOOT__`, and name the production and HMR entry paths. Vite build mode remains unchanged. + +No server restart or replacement is required merely because static artifacts changed. The host reads `index.html` and static assets on each request, while client bundles are also served from their current files with `no-cache`; a refresh of the existing URL is therefore the acceptance path after the relevant shell and plugin bundles are rebuilt. Starting a separate server proves only that a separate server works. If the user explicitly requests another long-running server, the existing managed background-task contract owns its lifecycle and completion notices; shell `&` is not an alternative lifecycle. + +## Verification + +The keyless fresh-round-trip browser scenario boots the shipped Web composition, drives a real replayed session, snapshots the URL-bearing system-prompt prefix, and invokes the assembled bash tool to prove `$DSH_WEB_URL` equals the scaffold's actual bound URL. A real Vite subprocess test requires serve mode to exit nonzero with the full-host correction. The real-Loader webserver test rewrites a static asset after the process binds and proves the same port returns the new bytes. These assertions inspect prompt state, process exit, shell output, and HTTP bytes rather than an agent's success statement. + +## Alternatives considered + +**Extend only the system prompt.** Rejected because it would leave the target unavailable to tools, preserve the misleading bare-Vite path, and fail to prove how an existing process observes rebuilt artifacts. + +**Remove the `apps/web` development script without guarding Vite.** Rejected because `npx vite`, the exact incident command, bypasses package scripts. Serve mode itself must fail. + +**Automatically restart or replace the current Web process after every edit.** Rejected because the static server already reads current artifacts per request, a restart would interrupt the session that requested the edit, and plugin HMR has a separate explicit `dsh web --dev` composition. + +**Send DOM, route, or screenshots with each request.** Deferred to a separate logged-input design. Stable URL identity closes this feedback loop without claiming browser state the host does not receive. + +## Consequences + +Web prompts gain a dynamic URL paragraph, so provider prefix reuse now varies by bound port. Bash processes gain one non-secret managed environment variable. Bare Vite can no longer be used as a shell-only visual sandbox; developers use the full host or build mode instead. In exchange, GUI work has one mechanically observable target, the unsupported startup path fails before a white screen, and a second port can no longer masquerade as proof that the user's current page changed. diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.zh.md new file mode 100644 index 0000000000..06367b09bd --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.zh.md @@ -0,0 +1,37 @@ +# Agent Note: Web GUI 改动在现有 URL 上闭环 + +Status: implemented + +[English](2026-07-28-web-gui-feedback-loop.md) | 中文 + +## 问题 + +Web agent(智能体)既无法识别承载当前会话的 GUI,也不知道用户正在查看哪个 URL。[运行时上下文决策](2026-07-28-web-agent-runtime-context.md)提供前一项事实,但 GUI 编辑仍然没有可执行的验收目标:源码编辑、产物构建、监听中的进程与用户已打开的页面只是互不关联的观察结果。仓库提供的入口让错误的替代方案显得合理,因为 `apps/web/package.json` 将 `vite` 暴露为 `dev` 脚本,而裸 Vite 即使无法注入 `window.__DSH_BOOT__`,仍会返回 HTTP 200。 + +事故会话记录了连续三次失败。修改主题后,第 2 轮把验收交给用户,要求用户运行 `pnpm run demo:tui` 或打开某个未指明的浏览器应用,自己没有执行任何真实组装的 Web 验证。第 3 轮读取前端包脚本,在 5173 端口启动裸 Vite,把 HTTP 200 当作就绪并报告成功;用户看到的却是符合预期的缺少 `__DSH_BOOT__` 的白屏。第 4 轮找到 `dsh web`,重新构建 Web 外壳,在 3334 端口启动了一个不受管理的 shell 后台进程,并且只检查新页面是否能返回 200 和启动 manifest(元数据清单),始终没有探测现有的 3081 端口。事实上,3081 端口的进程早于此次构建启动,其静态宿主会在下一次请求时读取重新构建的 dist,因此刷新原页面就已经能看到改动。直到用户报告这一事实,第 5 轮才检查 3081 端口并移除冗余服务。 + +## 决策 + +`dsh web` 发布一个规范的回环 URL,同时将其作为模型可见的界面定位信息和受管 shell 事实。`app:web-surface` 系统提示词段说明:未加限定的指代指向此 GUI;该段会给出 URL,并把验收定义为重新构建受影响的 Web 产物,然后刷新并验证现有 URL。`DSH_WEB_URL` 会把同一个值传入每次前台或受管后台 bash 调用,使 agent 无需解析提示词或进程列表即可查询目标。该段保留「不会隐式获得 DOM、路由或截图」这一边界,也不声称局域网别名等于浏览器中的实际地址。 + +`apps/web` 开发脚本和 Vite 配置都会在打开端口前拒绝服务模式。诊断信息会指出 `apps/web` 只是一个仅供构建的外壳,说明只有 `dsh web` 才会注入 `window.__DSH_BOOT__`,并给出生产入口与 HMR(热模块替换)入口路径。Vite 构建模式保持不变。 + +静态产物发生变化时,不需要仅为此重启或替换服务器。宿主会在每次请求时读取 `index.html` 和静态资源,客户端 bundle 也会从当前文件提供,并设置 `no-cache`;因此,重新构建相关外壳与插件 bundle 后,刷新现有 URL 就是验收路径。启动另一个服务器只能证明另一个服务器可用。如果用户明确要求再启动一个长期运行的服务器,则现有受管后台任务契约负责其生命周期和完成通知;shell `&` 不能替代这套生命周期机制。 + +## 验证 + +无密钥的 fresh-round-trip 浏览器场景会启动已交付的 Web 组合,驱动真实的回放会话,对包含 URL 的系统提示词前缀生成快照,并调用组装后的 bash 工具,证明 `$DSH_WEB_URL` 等于测试脚手架实际绑定的 URL。真实 Vite 子进程测试要求服务模式以非零状态退出,并给出改用完整宿主的纠正信息。真实 loader Web 服务器测试会在进程完成绑定后改写静态资源,并证明同一端口返回新的字节。这些断言检查提示词状态、进程退出状态、shell 输出和 HTTP 字节,而不是 agent 的成功声明。 + +## 考虑过的替代方案 + +**仅扩展系统提示词。** 不予采纳,因为这样会让工具仍然无法获得目标,保留具有误导性的裸 Vite 路径,并且无法证明现有进程如何观察重新构建的产物。 + +**删除 `apps/web` 开发脚本,但不为 Vite 添加防护。** 不予采纳,因为事故中实际使用的命令 `npx vite` 会绕过包脚本。服务模式本身必须失败。 + +**每次编辑后自动重启或替换当前 Web 进程。** 不予采纳,因为静态服务器本就会在每次请求时读取当前产物,重启还会中断发起编辑请求的会话,而插件 HMR 已有独立且显式的 `dsh web --dev` 组合。 + +**每次请求都发送 DOM、路由或截图。** 推迟到另行设计的已记录输入机制。稳定的 URL 身份足以闭合本次反馈循环,同时不会声称宿主掌握其未接收的浏览器状态。 + +## 影响 + +Web 提示词会增加一个动态 URL 段落,因此模型提供方的前缀复用会随绑定端口变化。Bash 进程会增加一个非敏感的受管环境变量。裸 Vite 不再能用作只依赖 shell 的视觉沙箱;开发者应改用完整宿主或构建模式。作为交换,GUI 工作有了一个可由机制观察的唯一目标,不受支持的启动路径会在出现白屏前失败,另一个端口也无法再冒充用户当前页面已经改动的证据。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index d1c6290a08..bd14674ff9 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: 87cf1597fbb1ce91a7d61f912c42b0d6a68b2eea -README.zh.md: b3743f57a4dafda71e437a04ee0148b32dba2dca +README.md: 3ec427a0bca501d70c9bca938692d6ef9557a2dd +README.zh.md: e0cf7cd399858822df613a98890c0872554a5085 diff --git a/apps/cli/README.md b/apps/cli/README.md index 87cf1597fb..3ec427a0bc 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -14,7 +14,7 @@ The TUI surface: - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. -The Web and headless surfaces boot one shared composition (`cordis.yml`): both tell the coding agent its model and session working directory, 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. Web additionally names the DeepSeek Harness Web GUI as the interaction surface and this checkout as its own source location; references such as “this page” therefore identify the GUI without claiming access to implicit DOM, route, or screenshot state. 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 one shared composition (`cordis.yml`): both tell the coding agent its model and session working directory, 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. Web additionally names the DeepSeek Harness Web GUI as the interaction surface, this checkout as its own source location, and the process's canonical local URL in both the prompt and `$DSH_WEB_URL`; references such as “this page” therefore identify the GUI without claiming access to implicit DOM, route, or screenshot state. The Web host reads frontend dist and client bundles when requests arrive, so rebuilding the affected artifacts and refreshing the existing URL updates that GUI without replacing its process; bare `apps/web` Vite serving fails because it cannot inject `window.__DSH_BOOT__`. 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`). `DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode). diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index b3743f57a4..e0cf7cd399 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -14,7 +14,7 @@ TUI 界面: - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 -Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都会告知编码 agent 所用模型和会话工作目录,将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。Web 还会明确说明交互界面是 DeepSeek Harness Web GUI,并把当前 checkout 标记为自身源码位置;因此,「这个页面」等表述会指向该 GUI,但 agent 不会声称可以访问未显式提供的 DOM、路由或截图状态。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 +Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都会告知编码 agent 所用模型和会话工作目录,将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。Web 还会明确说明交互界面是 DeepSeek Harness Web GUI、当前 checkout 是自身源码位置,并在提示词和 `$DSH_WEB_URL` 中提供该进程的规范本地 URL;因此,「这个页面」等表述会指向该 GUI,但 agent 不会声称可以访问未显式提供的 DOM、路由或截图状态。Web 宿主会在收到请求时读取前端 dist 和客户端 bundle,因此重新构建受影响的产物并刷新现有 URL 即可更新该 GUI,无须替换其进程;直接使用裸 `apps/web` Vite 服务会失败,因为它无法注入 `window.__DSH_BOOT__`。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 `DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。 diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 67da6c73e8..97e5f94e18 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -11,28 +11,47 @@ import { fileURLToPath } from 'node:url' import type { Context } from 'cordis' import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-system-prompt' +import type {} from '@deepseek-ai/dsh-tool-bash' import { AppCLIEntry } from './app-cli-entry.ts' const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) -/** Stable model-visible orientation for sessions created through `dsh web`. */ -const WEB_SURFACE_PROMPT = 'You are interacting with the user through the DeepSeek Harness Web GUI. ' - + 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. ' - + 'The browser provides no implicit DOM, route, or screenshot context.' +const DSH_WEB_URL = 'DSH_WEB_URL' as const + +/** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */ +function webSurfacePrompt(webUrl: string): string { + return `You are interacting with the user through the DeepSeek Harness Web GUI at ${webUrl}. ` + + 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. ' + + 'The browser provides no implicit DOM, route, or screenshot context. ' + + 'For changes to this GUI, rebuild the affected Web artifacts and verify this existing URL after a refresh; starting another server does not update this GUI. ' + + 'The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. ' + + 'Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.' +} /** - * Add the launcher-owned source location and Web-surface orientation after the - * shared config tree settles. The request header logs both sections with every - * model-visible prompt. + * Add launcher-owned source, Web-surface orientation, and the shell-visible + * canonical URL after the shared config tree settles. The request header logs + * the model-visible sections; each bash execution receives the same URL through + * the managed environment. * @param ctx - settled Web application context. * @param sourceRoot - absolute checkout root resolved from the launcher module. + * @param webUrl - canonical loopback URL printed by this Web process. */ -export function installWebPromptContext(ctx: Context, sourceRoot: string): void { +export function installWebPromptContext(ctx: Context, sourceRoot: string, webUrl: string): void { const systemPrompt = ctx.get('systemPrompt') if (systemPrompt === undefined) throw new Error('dsh web: systemPrompt service missing after settled boot') + const bashEnv = ctx.get('bashEnv') + if (bashEnv === undefined) throw new Error('dsh web: bashEnv service missing after settled boot') addHarnessSourceSection(ctx, sourceRoot) - systemPrompt.section({ name: 'app:web-surface', order: -98, text: WEB_SURFACE_PROMPT }) + systemPrompt.section({ name: 'app:web-surface', order: -98, text: webSurfacePrompt(webUrl) }) + bashEnv.register({ + name: 'web-runtime', + variables: { + [DSH_WEB_URL]: { description: 'Canonical local URL of the DeepSeek Harness Web GUI serving this session.' }, + }, + resolve: () => ({ [DSH_WEB_URL]: webUrl }), + }) } // Display-only mirrors of the webserver schema's allowed hosts: the loopback @@ -63,7 +82,8 @@ export async function runWeb( ...workspaceRoot !== undefined && { workspaceRoot }, }) const { ctx, port: boundPort } = await entry.run() - installWebPromptContext(ctx, SOURCE_ROOT) + const localUrl = `http://${LOOPBACK_HOST}:${boundPort}` + installWebPromptContext(ctx, SOURCE_ROOT, localUrl) let exiting = false const shutdown = (code: number): void => { @@ -76,7 +96,6 @@ export async function runWeb( ? Object.values(networkInterfaces()).flat() .find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal) : undefined - const localUrl = `http://${LOOPBACK_HOST}:${boundPort}` console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate.address}:${boundPort})`}`) process.on('SIGTERM', () => { shutdown(0) }) diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 05947889b2..f56e97cb60 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../packages/ui/app-boot" }, + { + "path": "../../packages/bash/tool-bash" + }, { "path": "../../packages/ui/tui" }, diff --git a/apps/web/package.json b/apps/web/package.json index 3c8f90b6a0..722ebe5342 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,7 +10,7 @@ }, "scripts": { "build": "vite build", - "dev": "vite", + "dev": "node -e \"console.error('apps/web is build-only; run dsh web or dsh web --dev with pnpm run dev:web') ; process.exit(1)\"", "watch": "vite build --watch" }, "license": "BSD-3-Clause", diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 222bb28580..a6434cd0d1 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -14,6 +14,7 @@ import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { CallId } from '@deepseek-ai/dsh-llm' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, @@ -87,9 +88,29 @@ describe('web e2e: fresh round trip through the real assembly', () => { const prefix = system.split('\n\n').slice(0, 4).join('\n\n') .split(REPO_ROOT).join('{{sourceRoot}}') .split(join(scaffold.workspaceCwd, 'workspace')).join('{{cwd}}') + .split(scaffold.baseUrl).join('{{webUrl}}') await compareOrRefreshGolden(SYSTEM_PROMPT_EXPECTED, prefix, MODE) }) + it('exposes the assembled Web URL to the real bash tool', async () => { + if (settledSessionId === undefined) throw new Error('the drive turn did not publish a session id') + const agent = scaffold.ctx.agents.get(settledSessionId) + if (agent === undefined) throw new Error(`the settled Web agent ${settledSessionId} is no longer live`) + const result = await scaffold.ctx.tools.execute({ + signal: AbortSignal.timeout(5_000), + callId: CallId('web-url-probe'), + name: 'bash', + arguments: { + command: 'printf \'%s\\n\' "$DSH_WEB_URL"', + description: 'Print current Web URL', + }, + agent, + }) + expect(result.isError).toBe(false) + expect(result.content.filter(block => block.type === 'text').map(block => block.text).join('')) + .toBe(`${scaffold.baseUrl}\n`) + }) + it.skipIf(MODE === 'record')('rendered the settled turn: markdown, tool row, composer restore', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-settled')) // Browser settled-poll after host completion (host strictly precedes render). diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index ac360f38f2..8c9ec1dd30 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -203,12 +203,12 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { + it('rejects the package dev alias with the full-host correction', async () => { + const result = await execa('pnpm', ['run', 'dev'], { cwd: WEB_ROOT, reject: false }) + expect(result.exitCode).not.toBe(0) + expect(result.stderr).toContain('apps/web is build-only') + expect(result.stderr).toContain('dsh web') + }) + + it('rejects the standalone Vite server with the full-host correction', async () => { + const result = await execa(join(WEB_ROOT, 'node_modules/.bin/vite'), ['--host', '127.0.0.1', '--port', '0'], { + cwd: WEB_ROOT, + reject: false, + timeout: 2_000, + }) + expect(result.exitCode).not.toBe(0) + expect(result.stderr).toContain('apps/web is not a standalone application') + expect(result.stderr).toContain('dsh web') + expect(result.stderr).toContain('window.__DSH_BOOT__') + }) +}) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 7043de4911..659dfa7ebd 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,11 +1,26 @@ import { fileURLToPath } from 'node:url' import { defineConfig } from 'vite' +import type { Plugin } from 'vite' import react from '@vitejs/plugin-react' const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url)) +const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. ' + + 'Build with `pnpm run build && pnpm run build:web`, then run `dsh web` (repository checkout: `pnpm run dsh -- web`). ' + + 'For client-plugin HMR, run `pnpm run dsh -- web --dev` together with `pnpm run dev:web`.' + +/** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */ +function rejectStandaloneServe(): Plugin { + const reject = (): never => { throw new Error(STANDALONE_ERROR) } + return { + name: 'dsh-reject-standalone-web-serve', + apply: 'serve', + configureServer: reject, + configurePreviewServer: reject, + } +} export default defineConfig({ - plugins: [react()], + plugins: [rejectStandaloneServe(), react()], resolve: { // Workspace packages resolve to SOURCE: package.json exports point at lib // for Node/type consumers, but the browser bundle must compile src directly diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index c4373d2e50..017fedba1a 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -111,6 +111,8 @@ describe('real Loader composition', () => { // Static fallback semantics: real asset served, traversal 403, non-GET/ // HEAD without a matching route 405. expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export {}' }) + await writeFile(join(root!, 'dist', 'app.js'), 'export const rebuilt = true') + expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export const rebuilt = true' }) expect((await request(port, '/..%2f..%2fetc%2fpasswd')).status).toBe(403) expect((await request(port, '/nowhere', { method: 'POST' })).status).toBe(405) From d8004e9956cc11a3678991db9dbbd87385602c1a Mon Sep 17 00:00:00 2001 From: NI0317 Date: Tue, 28 Jul 2026 22:12:00 +0800 Subject: [PATCH 04/23] test(web): prove Vite never binds a port --- apps/web/tests/vite-entry.e2e.ts | 31 +++++++++++++++++++++++++++++-- apps/web/vite.config.ts | 7 +++---- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/web/tests/vite-entry.e2e.ts b/apps/web/tests/vite-entry.e2e.ts index 80b9e2e683..131526819f 100644 --- a/apps/web/tests/vite-entry.e2e.ts +++ b/apps/web/tests/vite-entry.e2e.ts @@ -2,11 +2,28 @@ import { fileURLToPath } from 'node:url' import { join } from 'node:path' +import { createServer } from 'node:net' import { execa } from 'execa' import { describe, expect, it } from 'vitest' const WEB_ROOT = fileURLToPath(new URL('..', import.meta.url)) +/** Reserve an available loopback port, then release it for the child invocation. */ +async function freePort(): Promise { + const server = createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('port probe returned no address') + await new Promise((resolve, reject) => server.close((error) => { + if (error === undefined) resolve() + else reject(error) + })) + return address.port +} + describe('Web development entry', () => { it('rejects the package dev alias with the full-host correction', async () => { const result = await execa('pnpm', ['run', 'dev'], { cwd: WEB_ROOT, reject: false }) @@ -16,14 +33,24 @@ describe('Web development entry', () => { }) it('rejects the standalone Vite server with the full-host correction', async () => { - const result = await execa(join(WEB_ROOT, 'node_modules/.bin/vite'), ['--host', '127.0.0.1', '--port', '0'], { + const port = await freePort() + const result = await execa(join(WEB_ROOT, 'node_modules/.bin/vite'), ['--host', '127.0.0.1', '--port', String(port)], { cwd: WEB_ROOT, reject: false, - timeout: 2_000, + timeout: 10_000, }) + expect(result.timedOut).toBe(false) expect(result.exitCode).not.toBe(0) expect(result.stderr).toContain('apps/web is not a standalone application') expect(result.stderr).toContain('dsh web') expect(result.stderr).toContain('window.__DSH_BOOT__') + await expect(new Promise((resolve, reject) => { + const probe = createServer() + probe.once('error', reject) + probe.listen(port, '127.0.0.1', () => probe.close((error) => { + if (error === undefined) resolve() + else reject(error) + })) + })).resolves.toBeUndefined() }) }) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 659dfa7ebd..d624fc2472 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -10,12 +10,11 @@ const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite ca /** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */ function rejectStandaloneServe(): Plugin { - const reject = (): never => { throw new Error(STANDALONE_ERROR) } return { name: 'dsh-reject-standalone-web-serve', - apply: 'serve', - configureServer: reject, - configurePreviewServer: reject, + config(_config, env) { + if (env.command === 'serve') throw new Error(STANDALONE_ERROR) + }, } } From cd88a339fa885be82194e1881f49bf8be46190fe Mon Sep 17 00:00:00 2001 From: NI0317 Date: Wed, 29 Jul 2026 11:22:48 +0800 Subject: [PATCH 05/23] fix(web): verify current GUI updates end to end --- ...2026-07-28-web-gui-feedback-loop.i18n.yaml | 4 +- .../2026-07-28-web-gui-feedback-loop.md | 10 +- .../2026-07-28-web-gui-feedback-loop.zh.md | 12 +- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/src/args.ts | 2 +- apps/cli/src/web.ts | 27 +++- apps/web/package.json | 2 +- apps/web/tests/hmr-live.e2e.ts | 132 ++++++++++++++++++ apps/web/tests/replay-round-trip.e2e.ts | 6 +- apps/web/tests/scaffold.ts | 2 +- apps/web/tests/smoke-real.e2e.ts | 12 +- .../system-prompt.expected.md | 2 +- .../development-prompt.expected.md | 1 + apps/web/tests/support/listen-probe.mjs | 9 ++ apps/web/tests/vite-entry.e2e.ts | 47 ++++--- apps/web/tsconfig.json | 1 + ...0003-web-agent-gui-feedback-loop.i18n.yaml | 6 + .../0003-web-agent-gui-feedback-loop.md | 53 +++++++ .../0003-web-agent-gui-feedback-loop.zh.md | 53 +++++++ docs/postmortem/README.i18n.yaml | 6 +- docs/postmortem/README.md | 1 + docs/postmortem/README.zh.md | 1 + scripts/dev-web.spec.ts | 42 ++++++ scripts/dev-web.ts | 87 +++++++----- tsconfig.host.json | 1 + 27 files changed, 438 insertions(+), 89 deletions(-) create mode 100644 apps/web/tests/hmr-live.e2e.ts create mode 100644 apps/web/tests/snapshots/web-runtime-context/development-prompt.expected.md create mode 100644 apps/web/tests/support/listen-probe.mjs create mode 100644 docs/postmortem/0003-web-agent-gui-feedback-loop.i18n.yaml create mode 100644 docs/postmortem/0003-web-agent-gui-feedback-loop.md create mode 100644 docs/postmortem/0003-web-agent-gui-feedback-loop.zh.md create mode 100644 scripts/dev-web.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.i18n.yaml index 22390d728f..1e22fa79fb 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.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/bug-fix/2026-07-28-web-gui-feedback-loop.md -2026-07-28-web-gui-feedback-loop.md: 27295704d7b0cde3a46a6a28891545bfe31ed275 -2026-07-28-web-gui-feedback-loop.zh.md: 06367b09bd889bfb7a4052c46369720c4ec6d358 +2026-07-28-web-gui-feedback-loop.md: 039d2aebeeef903d10838a46b48e5172f0195126 +2026-07-28-web-gui-feedback-loop.zh.md: ddf748654788811aa19e2a8a295c3ff6df033fef diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md index 27295704d7..039d2aebee 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md @@ -8,11 +8,13 @@ English | [中文](2026-07-28-web-gui-feedback-loop.zh.md) The Web agent could identify neither the GUI hosting its session nor the URL the user was viewing. The [runtime-context decision](2026-07-28-web-agent-runtime-context.md) supplies the first fact, but a GUI edit still had no executable acceptance target: source edits, artifact builds, a listening process, and the user's existing page were unrelated observations. Repository affordances made a wrong substitute look valid because `apps/web/package.json` exposed `vite` as its `dev` script and bare Vite returned HTTP 200 even though it could not inject `window.__DSH_BOOT__`. -The incident session recorded three consecutive failures. After changing the theme, turn 2 delegated acceptance to the user with `pnpm run demo:tui` or an unspecified browser application and ran no assembled Web check. Turn 3 read the frontend package script, launched bare Vite on port 5173, treated HTTP 200 as readiness, and reported success; the user instead received the expected missing-`__DSH_BOOT__` white screen. Turn 4 found `dsh web`, rebuilt the shell, started an unmanaged shell-background process on port 3334, and checked only that the new page returned 200 with a boot manifest. It never probed the existing port 3081. In fact, the port-3081 process predated the build, and its static host read the rebuilt dist on the next request, so refreshing the original page already showed the change. Only after the user reported that fact did turn 5 inspect port 3081 and remove the redundant server. +The [incident post-mortem](../../../../docs/postmortem/0003-web-agent-gui-feedback-loop.md) owns the event-log timeline and why the original checks accepted the wrong page, process, and port. ## Decision -`dsh web` publishes one canonical loopback URL as both model-visible orientation and a managed shell fact. The `app:web-surface` prompt section says that unqualified references identify this GUI, names the URL, and defines acceptance as rebuilding the affected Web artifacts and verifying that existing URL after refresh. `DSH_WEB_URL` carries the same value into every foreground or managed background bash call, so the agent can query the target without parsing prose or process listings. The section preserves the no-implicit-DOM, route, or screenshot boundary and does not claim that a LAN alias equals the browser's literal address. +`dsh web` publishes one canonical loopback URL and its actual runtime mode as both model-visible orientation and managed shell facts. The `app:web-surface` prompt section says that unqualified references identify this GUI and names the URL; `DSH_WEB_URL` and `DSH_WEB_MODE=production|development` carry the same facts into every foreground or managed background bash call. The section preserves the no-implicit-DOM, route, or screenshot boundary and does not claim that a LAN alias equals the browser's literal address. + +The mode-specific prompt makes the agent, rather than the user, own the hidden startup contract. Production mode defines acceptance as rebuilding the affected artifacts and refreshing the existing URL. Development mode states that `dsh web --dev` activates only the HMR receiver: automatic client-plugin reload additionally requires a same-checkout `pnpm run dev:web` watcher, which the agent verifies before promising no-refresh updates. Shell and other plain-package changes still require rebuild plus refresh. An agent in production mode explains both commands when a user requests no-refresh updates; it does not launch a replacement GUI unless asked. The `apps/web` development script and Vite configuration reject serve mode before opening a port. Their diagnostics identify `apps/web` as a build-only shell, explain that only `dsh web` injects `window.__DSH_BOOT__`, and name the production and HMR entry paths. Vite build mode remains unchanged. @@ -20,7 +22,7 @@ No server restart or replacement is required merely because static artifacts cha ## Verification -The keyless fresh-round-trip browser scenario boots the shipped Web composition, drives a real replayed session, snapshots the URL-bearing system-prompt prefix, and invokes the assembled bash tool to prove `$DSH_WEB_URL` equals the scaffold's actual bound URL. A real Vite subprocess test requires serve mode to exit nonzero with the full-host correction. The real-Loader webserver test rewrites a static asset after the process binds and proves the same port returns the new bytes. These assertions inspect prompt state, process exit, shell output, and HTTP bytes rather than an agent's success statement. +The keyless fresh-round-trip browser scenario boots the shipped production Web composition, drives a real replayed session, snapshots the URL/mode-bearing system-prompt prefix, and invokes the assembled bash tool to prove `$DSH_WEB_URL` and `$DSH_WEB_MODE` match the actual bound runtime. The real CLI smoke launches `dsh web --dev` and captures the provider request, pinning the complete two-command development contract. The `dev:web` watcher test rebuilds an isolated client bundle after a source change; the browser HMR scenario launches `dsh web --dev`, changes an initial production-roster bundle, and observes the new DOM under the same page identity. A real Vite subprocess test requires serve mode to exit naturally with the full-host correction and instruments `Server.listen()` to prove it was never called. The real-Loader webserver test rewrites a static asset after the process binds and proves the same port returns the new bytes. These assertions inspect prompt state, process exit, shell output, DOM identity, and HTTP bytes rather than an agent's success statement. ## Alternatives considered @@ -34,4 +36,4 @@ The keyless fresh-round-trip browser scenario boots the shipped Web composition, ## Consequences -Web prompts gain a dynamic URL paragraph, so provider prefix reuse now varies by bound port. Bash processes gain one non-secret managed environment variable. Bare Vite can no longer be used as a shell-only visual sandbox; developers use the full host or build mode instead. In exchange, GUI work has one mechanically observable target, the unsupported startup path fails before a white screen, and a second port can no longer masquerade as proof that the user's current page changed. +Web prompts gain a dynamic URL-and-mode paragraph, so provider prefix reuse now varies by bound port and mode. Bash processes gain two non-secret managed environment variables. Bare Vite can no longer be used as a shell-only visual sandbox; developers use the full host or build mode instead. In exchange, GUI work has one mechanically observable target, the agent can teach the user the exact update behavior of the process actually serving their session, and the unsupported startup path fails before a white screen. The URL/mode contract guides the agent away from replacement ports; it does not prohibit arbitrary shell commands from starting one. diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.zh.md index 06367b09bd..ddf7486547 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.zh.md @@ -8,19 +8,21 @@ Status: implemented Web agent(智能体)既无法识别承载当前会话的 GUI,也不知道用户正在查看哪个 URL。[运行时上下文决策](2026-07-28-web-agent-runtime-context.md)提供前一项事实,但 GUI 编辑仍然没有可执行的验收目标:源码编辑、产物构建、监听中的进程与用户已打开的页面只是互不关联的观察结果。仓库提供的入口让错误的替代方案显得合理,因为 `apps/web/package.json` 将 `vite` 暴露为 `dev` 脚本,而裸 Vite 即使无法注入 `window.__DSH_BOOT__`,仍会返回 HTTP 200。 -事故会话记录了连续三次失败。修改主题后,第 2 轮把验收交给用户,要求用户运行 `pnpm run demo:tui` 或打开某个未指明的浏览器应用,自己没有执行任何真实组装的 Web 验证。第 3 轮读取前端包脚本,在 5173 端口启动裸 Vite,把 HTTP 200 当作就绪并报告成功;用户看到的却是符合预期的缺少 `__DSH_BOOT__` 的白屏。第 4 轮找到 `dsh web`,重新构建 Web 外壳,在 3334 端口启动了一个不受管理的 shell 后台进程,并且只检查新页面是否能返回 200 和启动 manifest(元数据清单),始终没有探测现有的 3081 端口。事实上,3081 端口的进程早于此次构建启动,其静态宿主会在下一次请求时读取重新构建的 dist,因此刷新原页面就已经能看到改动。直到用户报告这一事实,第 5 轮才检查 3081 端口并移除冗余服务。 +[事故复盘](../../../../docs/postmortem/0003-web-agent-gui-feedback-loop.md)记录事件日志时间线,以及原有检查为何会接受错误的页面、进程和端口。 ## 决策 -`dsh web` 发布一个规范的回环 URL,同时将其作为模型可见的界面定位信息和受管 shell 事实。`app:web-surface` 系统提示词段说明:未加限定的指代指向此 GUI;该段会给出 URL,并把验收定义为重新构建受影响的 Web 产物,然后刷新并验证现有 URL。`DSH_WEB_URL` 会把同一个值传入每次前台或受管后台 bash 调用,使 agent 无需解析提示词或进程列表即可查询目标。该段保留「不会隐式获得 DOM、路由或截图」这一边界,也不声称局域网别名等于浏览器中的实际地址。 +`dsh web` 发布一个规范的回环 URL 及其实际运行时模式,同时将二者作为模型可见的界面定位信息和受管 shell 事实。`app:web-surface` 提示词段说明:未加限定的指代指向此 GUI,并给出 URL;`DSH_WEB_URL` 和 `DSH_WEB_MODE=production|development` 会把同样的事实传入每次前台或受管后台 bash 调用。该段保留「不会隐式获得 DOM、路由或截图」这一边界,也不声称局域网别名等于浏览器中的实际地址。 -`apps/web` 开发脚本和 Vite 配置都会在打开端口前拒绝服务模式。诊断信息会指出 `apps/web` 只是一个仅供构建的外壳,说明只有 `dsh web` 才会注入 `window.__DSH_BOOT__`,并给出生产入口与 HMR(热模块替换)入口路径。Vite 构建模式保持不变。 +按模式区分的提示词让 agent 而非用户负责隐藏的启动契约。生产模式将验收定义为重新构建受影响的产物并刷新现有 URL。开发模式说明,`dsh web --dev` 只会启用 HMR(热模块替换)接收端:客户端插件要自动重新加载,还需要在同一检出中运行 `pnpm run dev:web` 监听进程,agent 会在承诺无需刷新即可更新前验证这一点。外壳和其他普通包的变更仍然需要重新构建并刷新。生产模式下的 agent 会在用户要求无需刷新即可更新时说明这两个命令;除非用户要求,否则不会启动替代 GUI。 + +`apps/web` 开发脚本和 Vite 配置都会在打开端口前拒绝服务模式。诊断信息会指出 `apps/web` 只是一个仅供构建的外壳,说明只有 `dsh web` 才会注入 `window.__DSH_BOOT__`,并给出生产入口与 HMR 入口路径。Vite 构建模式保持不变。 静态产物发生变化时,不需要仅为此重启或替换服务器。宿主会在每次请求时读取 `index.html` 和静态资源,客户端 bundle 也会从当前文件提供,并设置 `no-cache`;因此,重新构建相关外壳与插件 bundle 后,刷新现有 URL 就是验收路径。启动另一个服务器只能证明另一个服务器可用。如果用户明确要求再启动一个长期运行的服务器,则现有受管后台任务契约负责其生命周期和完成通知;shell `&` 不能替代这套生命周期机制。 ## 验证 -无密钥的 fresh-round-trip 浏览器场景会启动已交付的 Web 组合,驱动真实的回放会话,对包含 URL 的系统提示词前缀生成快照,并调用组装后的 bash 工具,证明 `$DSH_WEB_URL` 等于测试脚手架实际绑定的 URL。真实 Vite 子进程测试要求服务模式以非零状态退出,并给出改用完整宿主的纠正信息。真实 loader Web 服务器测试会在进程完成绑定后改写静态资源,并证明同一端口返回新的字节。这些断言检查提示词状态、进程退出状态、shell 输出和 HTTP 字节,而不是 agent 的成功声明。 +无密钥的 fresh-round-trip 浏览器场景会启动已交付的生产 Web 组合,驱动真实的回放会话,对包含 URL 和模式的系统提示词前缀生成快照,并调用组装后的 bash 工具,证明 `$DSH_WEB_URL` 和 `$DSH_WEB_MODE` 与实际绑定的运行时一致。真实 CLI 冒烟测试会启动 `dsh web --dev` 并捕获模型提供方请求,从而固定完整的双命令开发契约。`dev:web` watcher 测试会在源码发生变化后重新构建隔离的客户端 bundle;浏览器 HMR 场景会启动 `dsh web --dev`,修改生产初始 roster 中的 bundle,并在页面 identity 不变的情况下观察新 DOM。真实 Vite 子进程测试要求服务模式在给出改用完整宿主的纠正信息后自然退出,并通过插桩 `Server.listen()` 证明它从未被调用。真实 loader Web 服务器测试会在进程完成绑定后改写静态资源,并证明同一端口返回新的字节。这些断言检查提示词状态、进程退出状态、shell 输出、DOM identity 和 HTTP 字节,而不是 agent 的成功声明。 ## 考虑过的替代方案 @@ -34,4 +36,4 @@ Web agent(智能体)既无法识别承载当前会话的 GUI,也不知道 ## 影响 -Web 提示词会增加一个动态 URL 段落,因此模型提供方的前缀复用会随绑定端口变化。Bash 进程会增加一个非敏感的受管环境变量。裸 Vite 不再能用作只依赖 shell 的视觉沙箱;开发者应改用完整宿主或构建模式。作为交换,GUI 工作有了一个可由机制观察的唯一目标,不受支持的启动路径会在出现白屏前失败,另一个端口也无法再冒充用户当前页面已经改动的证据。 +Web 提示词会增加一个动态 URL 和模式段落,因此模型提供方的前缀复用会随绑定端口和模式变化。Bash 进程会增加两个非敏感的受管环境变量。裸 Vite 不再能用作只依赖 shell 的视觉沙箱;开发者应改用完整宿主或构建模式。作为交换,GUI 工作有了一个可由机制观察的唯一目标,agent 可以向用户说明实际承载其会话的进程究竟如何更新,不受支持的启动路径也会在出现白屏前失败。URL/模式契约会引导 agent 避免使用替代端口,但不会禁止任意 shell 命令启动替代服务。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index bd14674ff9..c82ea34050 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: 3ec427a0bca501d70c9bca938692d6ef9557a2dd -README.zh.md: e0cf7cd399858822df613a98890c0872554a5085 +README.md: 22bcd3e7dc8fafdf5c9608ef56230b3bd62e4a80 +README.zh.md: c28989de2b19c172998c876574eeb893a3aa4f92 diff --git a/apps/cli/README.md b/apps/cli/README.md index 3ec427a0bc..22bcd3e7dc 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -14,7 +14,7 @@ The TUI surface: - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. -The Web and headless surfaces boot one shared composition (`cordis.yml`): both tell the coding agent its model and session working directory, 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. Web additionally names the DeepSeek Harness Web GUI as the interaction surface, this checkout as its own source location, and the process's canonical local URL in both the prompt and `$DSH_WEB_URL`; references such as “this page” therefore identify the GUI without claiming access to implicit DOM, route, or screenshot state. The Web host reads frontend dist and client bundles when requests arrive, so rebuilding the affected artifacts and refreshing the existing URL updates that GUI without replacing its process; bare `apps/web` Vite serving fails because it cannot inject `window.__DSH_BOOT__`. 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 one shared composition (`cordis.yml`): both tell the coding agent its model and session working directory, 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. Web additionally names the DeepSeek Harness Web GUI as the interaction surface, this checkout as its own source location, and the process's canonical local URL and mode in both the prompt and managed `$DSH_WEB_URL`/`$DSH_WEB_MODE`; references such as “this page” therefore identify the GUI without claiming access to implicit DOM, route, or screenshot state. In production mode the host reads rebuilt frontend dist and client bundles on the next request, so refreshing the existing URL updates that GUI without replacing its process. `dsh web --dev` mounts the client-plugin HMR receiver, but no-refresh updates additionally require `pnpm run dev:web` in the same checkout to watch and rebuild plugin bundles; shell and plain-package changes still require a rebuild and page refresh. Bare `apps/web` Vite serving fails because it cannot inject `window.__DSH_BOOT__`. 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`). `DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode). diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index e0cf7cd399..c28989de2b 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -14,7 +14,7 @@ TUI 界面: - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 -Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都会告知编码 agent 所用模型和会话工作目录,将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。Web 还会明确说明交互界面是 DeepSeek Harness Web GUI、当前 checkout 是自身源码位置,并在提示词和 `$DSH_WEB_URL` 中提供该进程的规范本地 URL;因此,「这个页面」等表述会指向该 GUI,但 agent 不会声称可以访问未显式提供的 DOM、路由或截图状态。Web 宿主会在收到请求时读取前端 dist 和客户端 bundle,因此重新构建受影响的产物并刷新现有 URL 即可更新该 GUI,无须替换其进程;直接使用裸 `apps/web` Vite 服务会失败,因为它无法注入 `window.__DSH_BOOT__`。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 +Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都会告知编码 agent 所用模型和会话工作目录,将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。Web 还会明确说明交互界面是 DeepSeek Harness Web GUI、当前 checkout 是自身源码位置,并在提示词及受管的 `$DSH_WEB_URL`/`$DSH_WEB_MODE` 中提供该进程的规范本地 URL 和模式;因此,「这个页面」等表述会指向该 GUI,但 agent 不会声称可以访问未显式提供的 DOM、路由或截图状态。在生产模式下,宿主会在下次请求时读取重新构建的前端 dist 和客户端 bundle,因此刷新现有 URL 即可更新该 GUI,无须替换其进程。`dsh web --dev` 会挂载客户端插件的 HMR(热模块替换)接收端,但要实现无刷新更新,还需在同一 checkout 中运行 `pnpm run dev:web`,以监视并重新构建插件 bundle;shell 和普通包(package)的更改仍需重新构建并刷新页面。直接使用裸 `apps/web` Vite 服务会失败,因为它无法注入 `window.__DSH_BOOT__`。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 `DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。 diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 9fd0f4d9bf..0ea3c7e21c 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -115,7 +115,7 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc 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('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)') .option('--workspace-root ', 'parent directory for name-created workspaces') .action((options: WebOptions) => { // Commander parses the parent (default-surface) options on either side of diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 97e5f94e18..a801929e16 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -18,13 +18,23 @@ const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) const DSH_WEB_URL = 'DSH_WEB_URL' as const +const DSH_WEB_MODE = 'DSH_WEB_MODE' as const + +type WebMode = 'production' | 'development' /** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */ -function webSurfacePrompt(webUrl: string): string { +function webSurfacePrompt(webUrl: string, mode: WebMode): string { + const updateContract = mode === 'development' + ? 'This Web process was launched with `dsh web --dev`, so its client-plugin HMR receiver is active. ' + + 'No-refresh updates occur only when `pnpm run dev:web` is also running from this same checkout to rebuild client-plugin bundles; verify that watcher before promising automatic updates. ' + + 'Client-plugin changes then reload automatically, while apps/web shell and other plain-package changes still require a rebuild and page refresh. ' + : 'This Web process was launched without `--dev`, so HMR is inactive: rebuild the affected Web artifacts and verify this existing URL after a page refresh. ' + + 'If the user wants no-refresh client-plugin updates, explain that this GUI must be restarted with `dsh web --dev` and `pnpm run dev:web` must also run from this same checkout; do not present either command alone as sufficient. ' return `You are interacting with the user through the DeepSeek Harness Web GUI at ${webUrl}. ` + 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. ' + 'The browser provides no implicit DOM, route, or screenshot context. ' - + 'For changes to this GUI, rebuild the affected Web artifacts and verify this existing URL after a refresh; starting another server does not update this GUI. ' + + updateContract + + 'Starting another server does not update this GUI. ' + 'The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. ' + 'Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.' } @@ -37,20 +47,22 @@ function webSurfacePrompt(webUrl: string): string { * @param ctx - settled Web application context. * @param sourceRoot - absolute checkout root resolved from the launcher module. * @param webUrl - canonical loopback URL printed by this Web process. + * @param mode - whether this process mounted the client-plugin HMR receiver. */ -export function installWebPromptContext(ctx: Context, sourceRoot: string, webUrl: string): void { +export function installWebPromptContext(ctx: Context, sourceRoot: string, webUrl: string, mode: WebMode): void { const systemPrompt = ctx.get('systemPrompt') if (systemPrompt === undefined) throw new Error('dsh web: systemPrompt service missing after settled boot') const bashEnv = ctx.get('bashEnv') if (bashEnv === undefined) throw new Error('dsh web: bashEnv service missing after settled boot') addHarnessSourceSection(ctx, sourceRoot) - systemPrompt.section({ name: 'app:web-surface', order: -98, text: webSurfacePrompt(webUrl) }) + systemPrompt.section({ name: 'app:web-surface', order: -98, text: webSurfacePrompt(webUrl, mode) }) bashEnv.register({ name: 'web-runtime', variables: { [DSH_WEB_URL]: { description: 'Canonical local URL of the DeepSeek Harness Web GUI serving this session.' }, + [DSH_WEB_MODE]: { description: 'Web runtime mode: production, or development when the client-plugin HMR receiver is active.' }, }, - resolve: () => ({ [DSH_WEB_URL]: webUrl }), + resolve: () => ({ [DSH_WEB_URL]: webUrl, [DSH_WEB_MODE]: mode }), }) } @@ -65,7 +77,7 @@ const ALL_INTERFACES_HOST = '0.0.0.0' * through only when the flag was given; absent, the `cordis.yml` 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. + * @param dev - mount the client HMR receiver; `pnpm run dev:web` separately rebuilds watched plugin bundles. * @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback. */ export async function runWeb( @@ -83,7 +95,8 @@ export async function runWeb( }) const { ctx, port: boundPort } = await entry.run() const localUrl = `http://${LOOPBACK_HOST}:${boundPort}` - installWebPromptContext(ctx, SOURCE_ROOT, localUrl) + const mode: WebMode = dev ? 'development' : 'production' + installWebPromptContext(ctx, SOURCE_ROOT, localUrl, mode) let exiting = false const shutdown = (code: number): void => { diff --git a/apps/web/package.json b/apps/web/package.json index 722ebe5342..3c8f90b6a0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,7 +10,7 @@ }, "scripts": { "build": "vite build", - "dev": "node -e \"console.error('apps/web is build-only; run dsh web or dsh web --dev with pnpm run dev:web') ; process.exit(1)\"", + "dev": "vite", "watch": "vite build --watch" }, "license": "BSD-3-Clause", diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts new file mode 100644 index 0000000000..244dc0293d --- /dev/null +++ b/apps/web/tests/hmr-live.e2e.ts @@ -0,0 +1,132 @@ +/** Published dsh web --dev + pnpm dev:web → browser HMR, with no page reload. */ + +import { existsSync } from 'node:fs' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { chromium } from 'playwright' +import { expect, it } from 'vitest' +import { Context } from 'cordis' +import type { Fiber } from 'cordis' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { REPO_ROOT } from './support.ts' + +function spawnSpec(argv: readonly string[], cwd: string, env?: Record): SubprocessSpawnSpec { + return { + argv, + cwd, + stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }, + graceMs: 5_000, + ...env === undefined ? {} : { env }, + } +} + +function waitForOutput(child: SubprocessHandle, pattern: RegExp, label: string): Promise { + return new Promise((resolveReady, reject) => { + let output = '' + let settled = false + const cleanup = (): void => { + clearTimeout(timer) + child.stdout?.off('data', onData) + child.stderr?.off('data', onData) + } + const resolveOnce = (value: string): void => { + if (settled) return + settled = true + cleanup() + resolveReady(value) + } + const rejectOnce = (error: Error): void => { + if (settled) return + settled = true + cleanup() + reject(error) + } + const onData = (chunk: Buffer): void => { + output += chunk.toString() + const match = pattern.exec(output) + if (match === null) return + resolveOnce(match[1] ?? match[0]) + } + const timer = setTimeout(() => { rejectOnce(new Error(`${label} not ready:\n${output}`)) }, 60_000) + child.stdout?.on('data', onData) + child.stderr?.on('data', onData) + void child.done.then((outcome) => { + rejectOnce(new Error(`${label} exited before ready (${JSON.stringify(outcome)}):\n${output}`)) + }, (error: unknown) => { + rejectOnce(new Error(`${label} failed before ready:\n${output}`, { cause: error })) + }) + }) +} + +async function stopTree(child: SubprocessHandle): Promise { + child.terminate() + const stopped = await child.waitForExit(AbortSignal.timeout(15_000)) + if (!stopped) throw new Error(`process tree ${String(child.pid)} did not stop after termination escalation`) + await child.done +} + +it('hot-reloads a real client-plugin source edit without refreshing the page', async () => { + const world = await mkdtemp(join(tmpdir(), 'dsh-web-hmr-world-')) + const sourcePath = join(REPO_ROOT, 'packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx') + const bundlePath = join(REPO_ROOT, 'packages/client/ui-conversation/lib/client.js') + const binPath = join(REPO_ROOT, 'apps/cli/lib/bin.js') + if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first') + const originalSource = await readFile(sourcePath) + const originalBundle = await readFile(bundlePath) + const oldText = "Let's start building" + const sourceNeedle = 'Let's start building' + const newText = `HMR UPDATED ${'x'.repeat(80)}` + const updatedSource = originalSource.toString().replace(sourceNeedle, newText) + if (updatedSource === originalSource.toString()) throw new Error(`HMR source lacks ${JSON.stringify(sourceNeedle)}`) + + const subprocessCtx = new Context() + let subprocessFiber: Fiber | undefined + let watcher: SubprocessHandle | undefined + let host: SubprocessHandle | undefined + let browser: Awaited> | undefined + const failures: unknown[] = [] + try { + subprocessFiber = await subprocessCtx.plugin(LocalSubprocessService) + watcher = subprocessCtx.subprocess.spawn(spawnSpec(['pnpm', 'run', 'dev:web'], REPO_ROOT)) + await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web') + host = subprocessCtx.subprocess.spawn(spawnSpec( + [process.execPath, binPath, 'web', '--dev', '--port', '0'], + world, + { + DEEPSEEK_API_KEY: 'keyless-hmr-no-call', + DSH_HOME: join(world, '.dsh'), + }, + )) + const baseUrl = await waitForOutput(host, /dsh web: (http:\/\/[^\s]+)/, 'built dsh web --dev') + browser = await chromium.launch() + const page = await browser.newPage() + const pageErrors: string[] = [] + page.on('pageerror', error => pageErrors.push(String(error))) + await page.goto(baseUrl, { waitUntil: 'load' }) + await page.getByText(oldText, { exact: true }).waitFor({ timeout: 15_000 }) + const pageIdentity = await page.evaluate(() => { + const identity = crypto.randomUUID() + Object.defineProperty(window, '__dshHmrPageIdentity', { value: identity }) + return identity + }) + + await writeFile(sourcePath, updatedSource) + await page.getByText(newText, { exact: true }).waitFor({ timeout: 30_000 }) + expect(await page.evaluate(() => (window as Window & { __dshHmrPageIdentity?: string }).__dshHmrPageIdentity)) + .toBe(pageIdentity) + expect(pageErrors).toEqual([]) + } catch (error) { + failures.push(error) + } finally { + await writeFile(sourcePath, originalSource).catch((error: unknown) => failures.push(error)) + if (watcher !== undefined) await stopTree(watcher).catch((error: unknown) => failures.push(error)) + await writeFile(bundlePath, originalBundle).catch((error: unknown) => failures.push(error)) + if (host !== undefined) await stopTree(host).catch((error: unknown) => failures.push(error)) + await browser?.close().catch((error: unknown) => failures.push(error)) + await subprocessFiber?.dispose().catch((error: unknown) => failures.push(error)) + await rm(world, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) + } + if (failures.length > 0) throw new AggregateError(failures, 'HMR browser test or cleanup failed') +}, 120_000) diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index a6434cd0d1..e427d8def2 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -101,14 +101,14 @@ describe('web e2e: fresh round trip through the real assembly', () => { callId: CallId('web-url-probe'), name: 'bash', arguments: { - command: 'printf \'%s\\n\' "$DSH_WEB_URL"', - description: 'Print current Web URL', + command: 'printf \'%s\\n%s\\n\' "$DSH_WEB_URL" "$DSH_WEB_MODE"', + description: 'Print current Web runtime', }, agent, }) expect(result.isError).toBe(false) expect(result.content.filter(block => block.type === 'text').map(block => block.text).join('')) - .toBe(`${scaffold.baseUrl}\n`) + .toBe(`${scaffold.baseUrl}\nproduction\n`) }) it.skipIf(MODE === 'record')('rendered the settled turn: markdown, tool row, composer restore', async () => { diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 8c9ec1dd30..f6c6a106d4 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -208,7 +208,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { return new Promise((resolveReady, reject) => { let out = '' @@ -180,7 +182,7 @@ describe('dsh web keyless CLI smoke', () => { } }) - it('injects the invoking workspace AGENTS.md into the provider request', async () => { + it('routes --dev runtime context and workspace instructions through the real CLI request', async () => { requireDist() const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-workspace-')) mkdirSync(join(workspace, '.git')) @@ -212,7 +214,7 @@ describe('dsh web keyless CLI smoke', () => { const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href const child = spawn( process.execPath, - ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'], + ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0', '--dev'], { cwd: workspace, env: { @@ -241,6 +243,10 @@ describe('dsh web keyless CLI smoke', () => { ]) const workspaceMessage = captured.messages?.find(message => message.role === 'user' && message.content?.includes('web-workspace-context-probe')) + const systemMessage = captured.messages?.find(message => message.role === 'system') + const expectedWebSection = readFileSync(DEVELOPMENT_PROMPT, 'utf8').trimEnd() + .replace('{{webUrl}}', baseUrl) + expect(systemMessage?.content).toContain(expectedWebSection) expect(workspaceMessage).toMatchInlineSnapshot(` { "content": " diff --git a/apps/web/tests/snapshots/fresh-round-trip/system-prompt.expected.md b/apps/web/tests/snapshots/fresh-round-trip/system-prompt.expected.md index 352f164eb5..481b3d759c 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/system-prompt.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/system-prompt.expected.md @@ -2,6 +2,6 @@ You are an AI agent powered by the DeepSeek Harness SDK. Your own source code is the checkout at {{sourceRoot}}; you can read it there to learn how dsh works and how to extend it. -You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. For changes to this GUI, rebuild the affected Web artifacts and verify this existing URL after a refresh; starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL. +You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. This Web process was launched without `--dev`, so HMR is inactive: rebuild the affected Web artifacts and verify this existing URL after a page refresh. If the user wants no-refresh client-plugin updates, explain that this GUI must be restarted with `dsh web --dev` and `pnpm run dev:web` must also run from this same checkout; do not present either command alone as sufficient. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL. You are a coding agent powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. diff --git a/apps/web/tests/snapshots/web-runtime-context/development-prompt.expected.md b/apps/web/tests/snapshots/web-runtime-context/development-prompt.expected.md new file mode 100644 index 0000000000..58157d4437 --- /dev/null +++ b/apps/web/tests/snapshots/web-runtime-context/development-prompt.expected.md @@ -0,0 +1 @@ +You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. This Web process was launched with `dsh web --dev`, so its client-plugin HMR receiver is active. No-refresh updates occur only when `pnpm run dev:web` is also running from this same checkout to rebuild client-plugin bundles; verify that watcher before promising automatic updates. Client-plugin changes then reload automatically, while apps/web shell and other plain-package changes still require a rebuild and page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL. diff --git a/apps/web/tests/support/listen-probe.mjs b/apps/web/tests/support/listen-probe.mjs new file mode 100644 index 0000000000..c4708199c3 --- /dev/null +++ b/apps/web/tests/support/listen-probe.mjs @@ -0,0 +1,9 @@ +import { appendFileSync } from 'node:fs' +import { Server } from 'node:net' + +const marker = process.env.DSH_LISTEN_PROBE_MARKER +const listen = Server.prototype.listen +Server.prototype.listen = function (...args) { + if (marker !== undefined) appendFileSync(marker, 'listen\n') + return listen.apply(this, args) +} diff --git a/apps/web/tests/vite-entry.e2e.ts b/apps/web/tests/vite-entry.e2e.ts index 131526819f..1854cff948 100644 --- a/apps/web/tests/vite-entry.e2e.ts +++ b/apps/web/tests/vite-entry.e2e.ts @@ -1,7 +1,9 @@ /** Bare Vite must fail before it can present a bootless shell as a working GUI. */ -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { join } from 'node:path' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' import { createServer } from 'node:net' import { execa } from 'execa' import { describe, expect, it } from 'vitest' @@ -28,29 +30,34 @@ describe('Web development entry', () => { it('rejects the package dev alias with the full-host correction', async () => { const result = await execa('pnpm', ['run', 'dev'], { cwd: WEB_ROOT, reject: false }) expect(result.exitCode).not.toBe(0) - expect(result.stderr).toContain('apps/web is build-only') + expect(result.stderr).toContain('apps/web is not a standalone application') expect(result.stderr).toContain('dsh web') }) it('rejects the standalone Vite server with the full-host correction', async () => { + const probeRoot = mkdtempSync(join(tmpdir(), 'dsh-vite-listen-probe-')) + const marker = join(probeRoot, 'listen-called') const port = await freePort() - const result = await execa(join(WEB_ROOT, 'node_modules/.bin/vite'), ['--host', '127.0.0.1', '--port', String(port)], { - cwd: WEB_ROOT, - reject: false, - timeout: 10_000, - }) - expect(result.timedOut).toBe(false) - expect(result.exitCode).not.toBe(0) - expect(result.stderr).toContain('apps/web is not a standalone application') - expect(result.stderr).toContain('dsh web') - expect(result.stderr).toContain('window.__DSH_BOOT__') - await expect(new Promise((resolve, reject) => { - const probe = createServer() - probe.once('error', reject) - probe.listen(port, '127.0.0.1', () => probe.close((error) => { - if (error === undefined) resolve() - else reject(error) - })) - })).resolves.toBeUndefined() + try { + const probeModule = fileURLToPath(new URL('./support/listen-probe.mjs', import.meta.url)) + const result = await execa(join(WEB_ROOT, 'node_modules/.bin/vite'), ['--host', '127.0.0.1', '--port', String(port)], { + cwd: WEB_ROOT, + reject: false, + timeout: 10_000, + env: { + ...process.env, + DSH_LISTEN_PROBE_MARKER: marker, + NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --import ${pathToFileURL(probeModule).href}`.trim(), + }, + }) + expect(result.timedOut).toBe(false) + expect(result.exitCode).not.toBe(0) + expect(result.stderr).toContain('apps/web is not a standalone application') + expect(result.stderr).toContain('dsh web') + expect(result.stderr).toContain('window.__DSH_BOOT__') + expect(existsSync(marker), 'Vite called Server.listen before rejecting standalone serve mode').toBe(false) + } finally { + rmSync(probeRoot, { recursive: true, force: true }) + } }) }) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 559e72618b..4b26ec3d9b 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -31,6 +31,7 @@ "tests/settings-chrome.e2e.ts", "tests/workspace-management.e2e.ts", "tests/replay-round-trip.e2e.ts", + "tests/hmr-live.e2e.ts", "tests/seeded-history.e2e.ts", "tests/code-mode-round.e2e.ts", "tests/cordis-tool-round.e2e.ts" diff --git a/docs/postmortem/0003-web-agent-gui-feedback-loop.i18n.yaml b/docs/postmortem/0003-web-agent-gui-feedback-loop.i18n.yaml new file mode 100644 index 0000000000..838d489178 --- /dev/null +++ b/docs/postmortem/0003-web-agent-gui-feedback-loop.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 docs/postmortem/0003-web-agent-gui-feedback-loop.md +0003-web-agent-gui-feedback-loop.md: 13d13a607babfe7f5ddfdb6773c94f973bbef0db +0003-web-agent-gui-feedback-loop.zh.md: b3d35db9092304fcbca1106c0289fef441a2bad3 diff --git a/docs/postmortem/0003-web-agent-gui-feedback-loop.md b/docs/postmortem/0003-web-agent-gui-feedback-loop.md new file mode 100644 index 0000000000..13d13a607b --- /dev/null +++ b/docs/postmortem/0003-web-agent-gui-feedback-loop.md @@ -0,0 +1,53 @@ +# Post-mortem 0003: Web agent validated a replacement server instead of its current GUI + +English | [中文](0003-web-agent-gui-feedback-loop.zh.md) + +Status: resolved + +## Executive summary + +A Web agent changed the GUI source but did not know which URL and process hosted its session. It delegated acceptance to the user, then treated a bare Vite HTTP 200 as success despite a missing `window.__DSH_BOOT__` white screen, and finally validated a replacement `dsh web` server on another port while the original page had already picked up rebuilt artifacts. The fix makes the current URL and runtime mode model-visible and shell-queryable, rejects standalone Vite before listen, and verifies production refresh and development HMR against external state. + +## Summary + +The session ran inside the DeepSeek Harness Web GUI at port 3081 while its selected Workspace was an empty `test/` directory. The model request named neither the GUI nor its source checkout, URL, process, or update mode. Repository affordances exposed `apps/web` with a Vite development script, while the full browser composition lived behind `dsh web`. + +The resulting actions were individually plausible but did not share one acceptance target. A source edit, a successful build, an HTTP 200, an injected boot manifest, and the user's existing page were treated as interchangeable facts. + +The evidence source is the persisted event log for `session-3eb796c2-5159-4686-affe-df8719f6f987`, whose header records cwd `/Users/tn.shen/Documents/deepseek-harness-gui-master/test`. Its initial request header is sequence 6; the user-facing handoff, bare-Vite launch, replacement-host launch, boot-manifest probe, and first 3081 process probe are sequences 30939, 31865, 34309, 34441, and 34681 respectively. The timeline below follows those events rather than reconstructing intent from the later report. + +## Impact + +The user had to identify three consecutive mistakes: acceptance was delegated back to them; the proposed preview was a blank page; and the reported successful URL was not the page they were using. An unmanaged replacement server also outlived the turn until the user challenged it. + +No change in this investigation restarted or modified the read-only 3081 and 3082 trial services. + +## Timeline + +- In turn 2, after editing the theme, the agent's sequence-30939 message told the user to run `pnpm run demo:tui` or open an unspecified Web application. It ran no assembled Web acceptance. +- In turn 3, the agent read `apps/web/package.json`, launched bare Vite on port 5173 at sequence 31865, observed HTTP 200, and declared success. The browser instead threw `client-modules: window.__DSH_BOOT__ is missing or not an object` and rendered a white page. +- In turn 4, the agent found the full `dsh web` path, rebuilt the shell, launched an unmanaged process on port 3334 at sequence 34309, and checked only that this replacement returned 200 with a boot manifest at sequence 34441. It never probed port 3081. +- In turn 5, the user reported at sequence 34556 that 3081 already showed the new theme. Only then, at sequence 34681, did the agent inspect the existing process and remove the redundant server. + +## Root cause + +The Web assembly had no model-visible identity for the current GUI, canonical URL, or runtime mode. The session cwd correctly represented the user's selected Workspace, but the model mistook that project boundary for the application boundary. No durable contract related the GUI source checkout, built artifacts, serving process, target origin, and browser acceptance. + +The wrong startup path looked legitimate because bare Vite returned HTTP 200. `window.__DSH_BOOT__` is injected only by the full host, so transport readiness did not imply application readiness. The first regression test repeated this mistake in another form: a timeout killed Vite and satisfied a nonzero-exit assertion. Live reproduction exposed that false positive. + +Background process semantics were also bypassed with shell `&`, so task identity, completion notices, collection, and cleanup did not apply. Verifying port 3334 therefore proved only that a second service worked. + +## Guardrails added + +- The Web launcher publishes the canonical loopback URL and actual production/development mode in the logged `app:web-surface` prompt section and managed `$DSH_WEB_URL`/`$DSH_WEB_MODE` environment. +- Production guidance requires rebuilding artifacts and verifying the existing URL after refresh. Development guidance explains that `dsh web --dev` mounts only the HMR receiver; `pnpm run dev:web` in the same checkout must also rebuild client-plugin bundles, while shell and plain-package changes still require refresh. +- `apps/web` standalone Vite serve mode rejects during configuration. Its subprocess test proves natural exit and instruments `Server.listen()` so a transient bind cannot pass unnoticed. +- Layered real-path tests cover the CLI request, exact production/development prompts, shell runtime facts, same-port static replacement, source watcher rebuild, host stat polling, and browser HMR under an unchanged page identity. +- PR evidence preserves screenshots from the original 3081 session and a real-model before/after GUI run; external browser, HTTP, process, and session-log observations carry acceptance. + +## Lessons + +- The agent must know hidden runtime prerequisites before it can guide the user; startup mode is application context, not tribal knowledge. +- HTTP readiness, build success, and a boot manifest are different facts. Acceptance names the exact origin and externally observes the requested change there. +- A replacement service cannot prove that an existing page changed. Long-running processes use managed task lifecycles when they are actually requested. +- A regression test must be able to fail for the reported mechanism. Process timeout is not equivalent to fail-fast, and post-exit port availability does not prove the port was never bound. diff --git a/docs/postmortem/0003-web-agent-gui-feedback-loop.zh.md b/docs/postmortem/0003-web-agent-gui-feedback-loop.zh.md new file mode 100644 index 0000000000..b3d35db909 --- /dev/null +++ b/docs/postmortem/0003-web-agent-gui-feedback-loop.zh.md @@ -0,0 +1,53 @@ +# 事故复盘(postmortem) 0003:Web agent(智能体)验收了替代服务器,而非其当前 GUI + +[English](0003-web-agent-gui-feedback-loop.md) | 中文 + +Status: resolved + +## 摘要 + +Web agent 修改了 GUI 源码,却不知道由哪个 URL 和进程承载当前会话。它把验收交还给用户,随后在 `window.__DSH_BOOT__` 缺失导致白屏的情况下,仍把裸 Vite 返回的 HTTP 200 当作成功;最后,原页面其实已经加载了重建产物,它却去验收另一个端口上的替代 `dsh web` 服务器。修复让当前 URL 和运行模式对模型可见且可由 shell 查询,在独立 Vite 开始监听前拒绝启动,并依据外部状态验收生产模式刷新与开发模式 HMR(热模块替换)。 + +## 概述 + +该会话运行在端口 3081 的 DeepSeek Harness Web GUI 中,而用户选择的 Workspace 是空的 `test/` 目录。模型请求既未指明该 GUI,也未提供它的源码检出目录、URL、进程或更新模式。仓库在 `apps/web` 中提供了 Vite 开发脚本,完整的浏览器组合则由 `dsh web` 提供。 + +由此产生的各个动作单看都合理,却没有指向同一个验收目标。源码修改、成功构建、HTTP 200、注入的启动 manifest(元数据清单)和用户原本打开的页面,被当成了可以相互替代的事实。 + +证据源是 `session-3eb796c2-5159-4686-affe-df8719f6f987` 的持久化事件日志,其头部记录的 cwd 为 `/Users/tn.shen/Documents/deepseek-harness-gui-master/test`。初始请求头位于序列 6;面向用户的交接、裸 Vite 启动、替代宿主启动、启动 manifest 探测,以及首次探测 3081 进程,分别位于序列 30939、31865、34309、34441 和 34681。下方时间线以这些事件为依据,而不是根据后续报告反推意图。 + +## 影响 + +用户不得不连续指出三个错误:agent 把验收交还给用户;建议预览的页面一片空白;报告成功的 URL 并不是用户正在使用的页面。一个不受管理的替代服务器还持续运行到下一轮,直到用户提出质疑。 + +本次调查没有重启或修改只读的 3081 和 3082 试验服务。 + +## 时间线 + +- 在第 2 轮中,agent 修改主题后,在序列 30939 的消息中让用户运行 `pnpm run demo:tui` 或打开一个未明确指定的 Web 应用。它没有对组装后的 Web 应用执行任何验收。 +- 在第 3 轮中,agent 读取 `apps/web/package.json`,在序列 31865 于端口 5173 上启动裸 Vite,观察到 HTTP 200 后便宣布成功。浏览器却抛出 `client-modules: window.__DSH_BOOT__ is missing or not an object`,并显示白屏。 +- 在第 4 轮中,agent 找到了完整的 `dsh web` 启动路径,重新构建 shell,在序列 34309 于端口 3334 上启动一个不受管理的进程,并且只在序列 34441 检查了这个替代服务是否返回 200 和启动 manifest。它从未探测端口 3081。 +- 在第 5 轮中,用户在序列 34556 报告 3081 已经显示新主题。直到序列 34681,agent 才检查既有进程并移除冗余服务器。 + +## 根因 + +Web 组合没有向模型提供当前 GUI、规范 URL 或运行模式的身份信息。会话 cwd 正确表示了用户选择的 Workspace,但模型误把这个项目边界当成了应用边界。系统也没有持久契约将 GUI 源码检出目录、构建产物、服务进程、目标 origin 和浏览器验收关联起来。 + +裸 Vite 返回 HTTP 200,使错误的启动路径看似合理。`window.__DSH_BOOT__` 只由完整宿主注入,因此传输层就绪不代表应用已就绪。首个回归测试以另一种方式重复了同样的错误:超时机制终止 Vite 后,非零退出断言仍会通过。真实复现暴露了这一误报。 + +agent 还通过 shell `&` 绕过了后台进程语义,因此任务身份、完成通知、结果收集和清理机制均未生效。验证端口 3334 只能证明第二个服务可以工作。 + +## 已添加的防护措施 + +- Web 启动器在记录到日志的 `app:web-surface` 提示词区段,以及受管的 `$DSH_WEB_URL`/`$DSH_WEB_MODE` 环境变量中,发布规范环回 URL 和实际的生产/开发模式。 +- 生产模式指南要求重新构建产物,并在刷新后验证既有 URL。开发模式指南说明,`dsh web --dev` 只挂载 HMR 接收端;同一源码检出目录中的 `pnpm run dev:web` 还必须重新构建客户端插件 bundle,而 Web shell 和普通包的改动仍然需要刷新页面。 +- `apps/web` 的独立 Vite 服务模式会在配置阶段拒绝启动。其子进程测试验证进程自然退出,并插桩 `Server.listen()`,确保短暂绑定端口也不会漏检。 +- 分层的真实路径测试覆盖 CLI(命令行界面)请求、精确的生产/开发模式提示词、shell 运行时事实、同端口静态产物替换、源码 watcher 重建、宿主 stat 轮询,以及页面 identity 不变的浏览器 HMR。 +- PR(Pull Request)证据保留了原始 3081 会话的截图,以及真实模型驱动的 GUI 修改前后对比;验收以外部浏览器、HTTP、进程和会话日志的观测结果为准。 + +## 教训 + +- agent 必须先知道隐藏的运行时前置条件,才能指导用户;启动模式属于应用上下文,不应依赖团队口口相传。 +- HTTP 就绪、构建成功和启动 manifest 是不同的事实。验收必须明确指定确切的 origin,并从外部观察所请求的改动是否在该 origin 生效。 +- 替代服务无法证明既有页面已经改变。确实需要长时间运行的进程时,应使用受管的任务生命周期。 +- 回归测试必须能够针对所报告的机制失败。进程超时不等同于快速失败,进程退出后端口可用也不能证明该端口从未被绑定。 diff --git a/docs/postmortem/README.i18n.yaml b/docs/postmortem/README.i18n.yaml index e68d3a1a07..cbdfd07055 100644 --- a/docs/postmortem/README.i18n.yaml +++ b/docs/postmortem/README.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 -README.md: df0e2fcb8540aeed005153dbecc451d781ca5ff1 -README.zh.md: 2ce6de475c705b02cd9dabfb2181929d81478e2c +# pnpm run verify-translation-pairing --write docs/postmortem/README.md +README.md: 4858f8841e92a895f2d1a840b59b42758e83d952 +README.zh.md: f2f69e44448df8e7016fbe5672a0c0d5a522c47a diff --git a/docs/postmortem/README.md b/docs/postmortem/README.md index df0e2fcb85..4858f8841e 100644 --- a/docs/postmortem/README.md +++ b/docs/postmortem/README.md @@ -14,3 +14,4 @@ Every post-mortem opens with an **Executive summary**: one short paragraph a bus |---|---| | [0001](0001-acp-default-export-drops-inject.md) | ACP server crashed on connect: `export default` dropped the plugin's `inject` | | [0002](0002-js-expression-disabled-filesystem-tools.md) | Filesystem snapshot tools were permanently disabled by a literal `!!js` object | +| [0003](0003-web-agent-gui-feedback-loop.md) | Web agent validated a replacement server instead of the GUI hosting its session | diff --git a/docs/postmortem/README.zh.md b/docs/postmortem/README.zh.md index 2ce6de475c..f2f69e4444 100644 --- a/docs/postmortem/README.zh.md +++ b/docs/postmortem/README.zh.md @@ -14,3 +14,4 @@ |---|---| | [0001](0001-acp-default-export-drops-inject.md) | ACP(Agent Client Protocol)服务器在连接时崩溃:`export default` 丢失了插件的 `inject` | | [0002](0002-js-expression-disabled-filesystem-tools.md) | 文件系统快照工具被一个字面量 `!!js` 对象永久禁用 | +| [0003](0003-web-agent-gui-feedback-loop.md) | Web agent 验证了替代服务器,而非承载其会话的 GUI | diff --git a/scripts/dev-web.spec.ts b/scripts/dev-web.spec.ts new file mode 100644 index 0000000000..2edbc652ab --- /dev/null +++ b/scripts/dev-web.spec.ts @@ -0,0 +1,42 @@ +import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import type { TsdownBundle } from 'tsdown' +import { watchClientPlugins } from './dev-web.ts' + +it('rebuilds a client-plugin bundle after its source changes', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-watch-')) + let bundles: TsdownBundle[] = [] + try { + await symlink(join(import.meta.dirname, '..', 'node_modules'), join(root, 'node_modules'), 'dir') + await writeFile(join(root, 'package.json'), JSON.stringify({ name: '@dsh-test/dev-web-watch', private: true, type: 'module' })) + await writeFile(join(root, 'tsdown.config.ts'), ` +import { defineConfig } from 'tsdown' +export default defineConfig({ + entry: { client: 'src.ts' }, outDir: 'lib', format: 'cjs', platform: 'browser', dts: false, clean: false, + outputOptions: { entryFileNames: 'client.js' }, +}) +`) + const sourcePath = join(root, 'src.ts') + const bundlePath = join(root, 'lib/client.js') + await writeFile(sourcePath, 'export const version = "watch-v1"\n') + bundles = await watchClientPlugins(root, ['.'], 50) + await expect.poll(async () => { + try { + return (await readFile(bundlePath, 'utf8')).includes('watch-v1') + } catch { + return false + } + }, { timeout: 10_000 }).toBe(true) + + await new Promise(resolve => setTimeout(resolve, 1_000)) + await writeFile(sourcePath, `export const version = "watch-v2-${'x'.repeat(100)}"\n`) + await expect.poll(async () => (await readFile(bundlePath, 'utf8')).includes('watch-v2-'), { + timeout: 10_000, + }).toBe(true) + } finally { + for (const bundle of bundles) await bundle[Symbol.asyncDispose]() + await rm(root, { recursive: true, force: true }) + } +}, 20_000) diff --git a/scripts/dev-web.ts b/scripts/dev-web.ts index 38b1cffde1..aee7146487 100644 --- a/scripts/dev-web.ts +++ b/scripts/dev-web.ts @@ -18,9 +18,10 @@ * keys under each package's file config, and no package config defines it). */ import { globSync, readFileSync } from 'node:fs' -import { dirname, join, sep } from 'node:path' -import { fileURLToPath } from 'node:url' +import { dirname, join, resolve, sep } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' import { build } from 'tsdown' +import type { TsdownBundle } from 'tsdown' const repoRoot = fileURLToPath(new URL('..', import.meta.url)) @@ -29,46 +30,64 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url)) * whose package.json carries `dshClient` with platform "web" is a client * plugin bundle emitter. Scanned once at startup — a package added while * watching means restarting this script. + * @param root - repository root containing the grouped package directories. * @returns workspace-relative plugin package directories. */ -function discoverPluginDirs(): string[] { +export function discoverPluginDirs(root = repoRoot): string[] { const dirs: string[] = [] - for (const manifestPath of globSync('packages/*/*/package.json', { cwd: repoRoot }).sort()) { - const manifest = JSON.parse(readFileSync(join(repoRoot, manifestPath), 'utf8')) as { dshClient?: { platform?: unknown } } + for (const manifestPath of globSync('packages/*/*/package.json', { cwd: root }).sort()) { + const manifest = JSON.parse(readFileSync(join(root, manifestPath), 'utf8')) as { dshClient?: { platform?: unknown } } if (manifest.dshClient?.platform === 'web') dirs.push(dirname(manifestPath).split(sep).join('/')) } return dirs } -const PLUGIN_DIRS = discoverPluginDirs() -if (PLUGIN_DIRS.length === 0) { - console.error('dev-web: no dshClient (platform "web") packages found under packages/') - process.exit(1) +/** + * Start the tsdown watch build used by `pnpm run dev:web`. + * @param root - repository or fixture root passed to tsdown. + * @param pluginDirs - workspace-relative package directories to watch. + * @param pollInterval - optional source-watcher polling interval in milliseconds. + * @returns live bundles whose async disposers stop every watcher. + */ +export async function watchClientPlugins( + root: string, + pluginDirs: readonly string[], + pollInterval?: number, +): Promise { + return build({ + cwd: root, + workspace: [...pluginDirs], + watch: true, + ...pollInterval !== undefined + ? { inputOptions: { watch: { watcher: { usePolling: true, pollInterval } } } } + : {}, + }) } -const args = process.argv.slice(2) -const pollArg = args.find(a => a === '--poll' || a.startsWith('--poll=')) -if (args.some(a => a !== pollArg)) { - console.error('dev-web: usage: tsx scripts/dev-web.ts [--poll[=ms]]') - process.exit(1) -} -const pollInterval = pollArg === undefined ? undefined : Number(pollArg.split('=')[1] ?? '500') -if (pollInterval !== undefined && (!Number.isInteger(pollInterval) || pollInterval <= 0)) { - console.error(`dev-web: invalid --poll interval "${pollArg ?? ''}"`) - process.exit(1) -} +const invokedPath = process.argv[1] +const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href +if (isMain) { + const pluginDirs = discoverPluginDirs() + if (pluginDirs.length === 0) { + console.error('dev-web: no dshClient (platform "web") packages found under packages/') + process.exit(1) + } -await build({ - cwd: repoRoot, - workspace: PLUGIN_DIRS, - watch: true, - // Rolldown watch options ride through inputOptions (tsdown has no watcher - // tuning of its own); polling is opt-in for network mounts without inotify. - ...pollInterval !== undefined - ? { inputOptions: { watch: { watcher: { usePolling: true, pollInterval } } } } - : {}, -}) -console.log( - `dev-web: watching ${String(PLUGIN_DIRS.length)} dshClient plugin packages` - + `${pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : ''}:\n ${PLUGIN_DIRS.join('\n ')}`, -) + const args = process.argv.slice(2) + const pollArg = args.find(a => a === '--poll' || a.startsWith('--poll=')) + if (args.some(a => a !== pollArg)) { + console.error('dev-web: usage: tsx scripts/dev-web.ts [--poll[=ms]]') + process.exit(1) + } + const pollInterval = pollArg === undefined ? undefined : Number(pollArg.split('=')[1] ?? '500') + if (pollInterval !== undefined && (!Number.isInteger(pollInterval) || pollInterval <= 0)) { + console.error(`dev-web: invalid --poll interval "${pollArg ?? ''}"`) + process.exit(1) + } + + await watchClientPlugins(repoRoot, pluginDirs, pollInterval) + console.log( + `dev-web: watching ${String(pluginDirs.length)} dshClient plugin packages` + + `${pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : ''}:\n ${pluginDirs.join('\n ')}`, + ) +} diff --git a/tsconfig.host.json b/tsconfig.host.json index 9cf2a86bda..920f776876 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -18,6 +18,7 @@ "apps/web/tests/settings-chrome.e2e.ts", "apps/web/tests/workspace-management.e2e.ts", "apps/web/tests/replay-round-trip.e2e.ts", + "apps/web/tests/hmr-live.e2e.ts", "apps/web/tests/seeded-history.e2e.ts", "apps/web/tests/code-mode-round.e2e.ts", "apps/web/tests/cordis-tool-round.e2e.ts", From 6cd9fefe88a978429cfc8582e8bec9a99102c0a6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:49:58 +0800 Subject: [PATCH 06/23] fix(cli): register web prompt context before boot --- ...-07-28-web-agent-runtime-context.i18n.yaml | 4 +-- .../2026-07-28-web-agent-runtime-context.md | 4 +-- ...2026-07-28-web-agent-runtime-context.zh.md | 4 +-- apps/cli/src/app-cli-entry.ts | 9 ++++-- apps/cli/src/web.ts | 21 +++++++------- apps/cli/tests/web-prompt-context.spec.ts | 29 +++++++++++++++++++ apps/web/tests/scaffold.ts | 4 +-- 7 files changed, 54 insertions(+), 21 deletions(-) create mode 100644 apps/cli/tests/web-prompt-context.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.i18n.yaml index f49f2d8314..483bfd9e86 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.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/bug-fix/2026-07-28-web-agent-runtime-context.md -2026-07-28-web-agent-runtime-context.md: 8423f2d5542b7f9c841ecbf354fbea2e25699d13 -2026-07-28-web-agent-runtime-context.zh.md: cfb754bd92836142556638e9195050dfc282146e +2026-07-28-web-agent-runtime-context.md: 449c9d4ba2b144d02dee4b98ae80c86815aec5c1 +2026-07-28-web-agent-runtime-context.zh.md: def1674be5f193739bfb214a24f34590ee075d5f diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.md index 8423f2d554..449c9d4ba2 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.md @@ -10,13 +10,13 @@ The shared CLI base configured an empty deployment persona, the Web overlay did ## Decision -The shared Web/headless overlay (`apps/cli/config/web.cordis.yml`) supplies a concise coding-agent persona containing the resolved `{{model}}` and session `{{cwd}}`. `dsh web` additionally resolves the harness checkout from the launcher's module URL, installs the existing `harness:source` section, and adds an `app:web-surface` section before serving requests. The [source-checkout/workdir decision](2026-07-30-source-checkout-workdir-distinction.md) owns the source section's wording and its warning not to infer one path from the other. +The shared Web/headless overlay (`apps/cli/config/web.cordis.yml`) supplies a concise coding-agent persona containing the resolved `{{model}}` and session `{{cwd}}`. `dsh web` additionally resolves the harness checkout from the launcher's module URL, installs the existing `harness:source` section, and adds an `app:web-surface` section before serving requests. The launcher registers that setup before mounting the config tree; its `systemPrompt` injection therefore installs both sections before later prompt consumers such as the agent loop can activate and emit a request header. The [source-checkout/workdir decision](2026-07-30-source-checkout-workdir-distinction.md) owns the source section's wording and its warning not to infer one path from the other. The Web section treats unqualified references to “this page,” “this GUI,” or “this app” as references to the DeepSeek Harness Web GUI. It also states that the browser provides no implicit DOM, route, or screenshot context, so the model can identify the product without claiming visual state it did not receive. The assembled text is logged in `request/header`, preserving the model-visible/logged invariant. ## Verification -The keyless fresh-round-trip Web scenario boots the shipped base plus Web overlay, installs the same launcher context as `dsh web`, runs a real session through the HTTP/SSE application, and snapshots the first four system-prompt sections with source and working-directory paths normalized. The snapshot pins the harness identity, source checkout, Web orientation, and resolved coding-agent persona in request order. +The focused startup-order test registers a later `systemPrompt` consumer and proves that it observes both launcher sections on its first activation. The keyless fresh-round-trip Web scenario boots the shipped base plus Web overlay, registers the same launcher context as `dsh web`, runs a real session through the HTTP/SSE application, and snapshots the first four system-prompt sections with source and working-directory paths normalized. The snapshot pins the harness identity, source checkout, Web orientation, and resolved coding-agent persona in request order. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.zh.md index cfb754bd92..def1674be5 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.zh.md @@ -10,13 +10,13 @@ CLI 共享 base 配置了空的部署 persona,Web overlay 没有替换它, ## 决策 -`apps/cli/config/web.cordis.yml` 这份 Web/无头共享 overlay 提供一段简洁的编码 agent persona,其中包含解析后的 `{{model}}` 与会话 `{{cwd}}`。`dsh web` 还会根据启动器模块的 URL 解析 harness checkout,安装现有的 `harness:source` 提示词段,并在对外提供请求服务前添加 `app:web-surface` 提示词段。源码提示词段的措辞,以及其中不得从一条路径推断另一条路径的警告,均由另行记录的[源码 checkout 与工作目录区分决策](2026-07-30-source-checkout-workdir-distinction.md)负责。 +`apps/cli/config/web.cordis.yml` 这份 Web/无头共享 overlay 提供一段简洁的编码 agent persona,其中包含解析后的 `{{model}}` 与会话 `{{cwd}}`。`dsh web` 还会根据启动器模块的 URL 解析 harness checkout,安装现有的 `harness:source` 提示词段,并在对外提供请求服务前添加 `app:web-surface` 提示词段。启动器会在挂载配置树前注册这项设置;因此,它的 `systemPrompt` 注入会在 agent loop(智能体循环)等后续提示词消费方激活并发出 request header 之前安装这两个提示词段。源码提示词段的措辞,以及其中不得从一条路径推断另一条路径的警告,均由另行记录的[源码 checkout 与工作目录区分决策](2026-07-30-source-checkout-workdir-distinction.md)负责。 Web 提示词段把未限定的「这个页面」「这个 GUI」或「这个应用」解释为 DeepSeek Harness Web GUI。同时,它会明确说明浏览器不会隐式提供 DOM、路由或截图上下文,使模型能够识别产品,但不会声称掌握未收到的视觉状态。组装后的文本会记录在 `request/header` 中,从而保持「模型可见内容必须有日志记录」这一不变量。 ## 验证 -无密钥的 Web fresh-round-trip 场景会启动已交付的 base 与 Web overlay,安装与 `dsh web` 相同的启动器上下文,并通过 HTTP/SSE 应用运行一个真实会话。测试会把源码路径和工作目录规范化,然后对系统提示词的前四个段落生成快照。该快照按请求顺序固定 harness 身份、源码 checkout、Web 界面定位,以及解析后的编码 agent persona。 +聚焦启动顺序的测试会注册一个后续的 `systemPrompt` 消费方,并证明该消费方首次激活时就能观察到启动器的两个提示词段。无密钥的 Web fresh-round-trip 场景会启动已交付的 base 与 Web overlay,注册与 `dsh web` 相同的启动器上下文,并通过 HTTP/SSE 应用运行一个真实会话。测试会把源码路径和工作目录规范化,然后对系统提示词的前四个段落生成快照。该快照按请求顺序固定 harness 身份、源码 checkout、Web 界面定位,以及解析后的编码 agent persona。 ## 考虑过的替代方案 diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 218df73530..7b182b4073 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -155,6 +155,8 @@ export interface AppCLIEntryOptions { workspaceRoot?: string /** Extra authorities for the /api browser-trust fence (`host` or `host:port`), appended to the derived LAN IP literals. */ trustedHosts?: string[] + /** Surface setup registered after Loader installation and before any config-tree entry mounts. */ + prepare?: (ctx: Context) => Promise | void } /** @@ -180,8 +182,8 @@ export class AppCLIEntry { constructor(private readonly options: AppCLIEntryOptions) {} /** - * Run the boot chain: patch composition → Loader include boot (dev row - * before await) → fail-loud triple. + * Run the boot chain: patch composition → Loader installation → surface + * preparation → config-tree boot (dev row before await) → fail-loud triple. * @returns the settled root context and the listening port. */ async run(): Promise<{ ctx: Context; port: number }> { @@ -246,7 +248,7 @@ export class AppCLIEntry { if (telemetryPatch !== undefined) this.patches.push(telemetryPatch) } - /** Shared Loader boot; the dev HMR row mounts before await so the activation audit covers it. */ + /** Shared Loader boot; surface preparation precedes the tree, and the dev HMR row precedes the activation audit. */ private async bootTree(): Promise { // One include of the shared base with every overlay as a sibling patch // list: patches never cross an include boundary, so nesting them would @@ -260,6 +262,7 @@ export class AppCLIEntry { ...this.patches, ] this.ctx = await boot('dsh', resolve(this.options.configPath), patches, async (ctx) => { + await this.options.prepare?.(ctx) if (this.options.dev) await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' }) }) } diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 8510f74929..730ccdde93 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -23,17 +23,18 @@ const WEB_SURFACE_PROMPT = 'You are interacting with the user through the DeepSe + 'The browser provides no implicit DOM, route, or screenshot context.' /** - * Add the launcher-owned source location and Web-surface orientation after the - * shared config tree settles. The request header logs both sections with every - * model-visible prompt. - * @param ctx - settled Web application context. + * Register the launcher-owned source location and Web-surface orientation + * before the shared config tree mounts. The injection installs both sections + * when `systemPrompt` activates; because it precedes the Loader entries, later + * prompt consumers observe them on their first activation. + * @param ctx - Web root context with Loader installed but no config tree mounted. * @param sourceRoot - absolute checkout root resolved from the launcher module. */ -export function installWebPromptContext(ctx: Context, sourceRoot: string): void { - const systemPrompt = ctx.get('systemPrompt') - if (systemPrompt === undefined) throw new Error('dsh web: systemPrompt service missing after settled boot') - addHarnessSourceSection(ctx, sourceRoot) - systemPrompt.section({ name: 'app:web-surface', order: -98, text: WEB_SURFACE_PROMPT }) +export function prepareWebPromptContext(ctx: Context, sourceRoot: string): void { + ctx.inject(['systemPrompt'], (promptCtx) => { + addHarnessSourceSection(promptCtx, sourceRoot) + promptCtx.systemPrompt.section({ name: 'app:web-surface', order: -98, text: WEB_SURFACE_PROMPT }) + }) } // Display-only mirror of the webserver schema's loopback host: the address the @@ -65,13 +66,13 @@ export async function runWeb( overlayPath: WEB_OVERLAY, ...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) }, dev, + prepare: (ctx) => { prepareWebPromptContext(ctx, SOURCE_ROOT) }, ...host !== undefined && { host }, ...port !== undefined && { port }, ...workspaceRoot !== undefined && { workspaceRoot }, ...trustedHosts !== undefined && { trustedHosts }, }) const { ctx, port: boundPort } = await entry.run() - installWebPromptContext(ctx, SOURCE_ROOT) let exiting = false const shutdown = (code: number): void => { diff --git a/apps/cli/tests/web-prompt-context.spec.ts b/apps/cli/tests/web-prompt-context.spec.ts new file mode 100644 index 0000000000..f19b691ddf --- /dev/null +++ b/apps/cli/tests/web-prompt-context.spec.ts @@ -0,0 +1,29 @@ +import { sep } from 'node:path' +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import { HARNESS_SOURCE_SECTION } from '@deepseek-ai/dsh-app-boot' +import { prepareWebPromptContext } from '../src/web.ts' + +describe('prepareWebPromptContext', () => { + it('installs both sections before a later systemPrompt consumer activates', async () => { + const ctx = new Context() + const sourceRoot = `${sep}opt${sep}harness-src` + let observedNames: string[] | undefined + try { + prepareWebPromptContext(ctx, sourceRoot) + const consumer = ctx.inject(['systemPrompt'], async (promptCtx) => { + const assembly = await promptCtx.systemPrompt.assemble() + observedNames = assembly.sections.map(section => section.name) + }) + + await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent.' }) + await consumer + + expect(observedNames).toContain(HARNESS_SOURCE_SECTION) + expect(observedNames).toContain('app:web-surface') + } finally { + await ctx.fiber.dispose() + } + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index dfe342b5d4..226471227d 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -53,7 +53,7 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' // Empty type imports carry the httpServer/agents/sessionPersistence Context merges. import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-agent' -import { installWebPromptContext } from '../../cli/src/web.ts' +import { prepareWebPromptContext } from '../../cli/src/web.ts' import { DIST_INDEX, REPO_ROOT, requireDist } from './support.ts' /** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the ACP/TUI suites). */ @@ -301,13 +301,13 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise Date: Sun, 2 Aug 2026 00:57:15 +0800 Subject: [PATCH 07/23] chore(bash): mark bashEnv ownership FIXME --- packages/bash/tool-bash/src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 311548b520..3b3874bc59 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -354,6 +354,9 @@ const BACKGROUND_OUTPUT_PROPERTIES = { } as const export function apply(ctx: Context, config: Config = {}): void { + // FIXME(bash-env-ownership): Move ctx.bashEnv to a tool-independent shell + // environment plugin; replacing this tool with persistent Bash must not + // remove the managed DSH_* contributor seam. const bashEnv = new BashEnvRegistry(ctx, config) bashEnv.register({ name: 'session-persistence', From 52a715d3033b4a3a70d2495e282bbbd14b0f0af4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 01:13:58 +0800 Subject: [PATCH 08/23] fix(web): allow profiles without native bash env --- apps/cli/src/web.ts | 9 --------- apps/web/tests/scaffold.ts | 3 +-- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 712debfaf0..815a53a11d 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -82,14 +82,6 @@ export function prepareWebRuntimeContext(ctx: Context, sourceRoot: string, mode: }) } -/** - * Fail a settled Web boot whose composition omitted the managed Bash environment registry. - * @param ctx - settled Web application context. - */ -export function assertWebRuntimeContext(ctx: Context): void { - if (ctx.get('bashEnv') === undefined) throw new Error('dsh web: bashEnv service missing after settled boot') -} - /** * Serve the browser UI from the shipped config tree. `host`/`port` are passed * through only when the flag was given; absent, the shipped Web overlay value stands. @@ -123,7 +115,6 @@ export async function runWeb( ...trustedHosts !== undefined && { trustedHosts }, }) const { ctx, port: boundPort } = await entry.run() - assertWebRuntimeContext(ctx) const resolvedLocalWebUrl = localWebUrl(ctx) let exiting = false diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 11c46d90e8..b53bcd4486 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -53,7 +53,7 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' // Empty type imports carry the httpServer/agents/sessionPersistence Context merges. import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-agent' -import { assertWebRuntimeContext, prepareWebRuntimeContext } from '../../cli/src/web.ts' +import { prepareWebRuntimeContext } from '../../cli/src/web.ts' import { DIST_INDEX, REPO_ROOT, requireDist } from './support.ts' /** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the ACP/TUI suites). */ @@ -318,7 +318,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise Date: Thu, 30 Jul 2026 03:11:16 +0800 Subject: [PATCH 09/23] fix(cordis): make config reload transactional --- ...-20-config-hot-reload-resilience.i18n.yaml | 6 +- ...2026-07-20-config-hot-reload-resilience.md | 31 +-- ...6-07-20-config-hot-reload-resilience.zh.md | 31 +-- docs/cordis-catalog/core/fiber.md | 6 +- docs/cordis-catalog/events.md | 3 +- .../stderr.expected.txt | 4 +- .../host/directory-picker-auto/src/index.ts | 8 +- .../tests/loader-composition.spec.ts | 2 +- packages/host/webserver/README.i18n.yaml | 4 +- packages/host/webserver/README.md | 2 +- packages/host/webserver/README.zh.md | 2 +- .../host/webserver/tests/webserver.spec.ts | 24 +- packages/typert/loader/tests/loader.spec.ts | 4 +- packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 6 +- packages/ui/app-boot/README.zh.md | 6 +- packages/ui/app-boot/package.json | 2 + packages/ui/app-boot/src/index.ts | 81 +++--- packages/ui/app-boot/tests/app-boot.spec.ts | 48 +++- .../ui/app-boot/tests/config-reload.spec.ts | 236 +++++++++++++++++- packages/ui/app-boot/tests/hmr-config.spec.ts | 142 +++++++++++ pnpm-lock.yaml | 6 + scripts/gen-cordis-catalog.ts | 3 +- vendor/README.md | 9 +- vendor/cordis/src/events.ts | 2 +- vendor/cordis/src/fiber.ts | 6 +- vendor/hmr/src/index.ts | 164 ++++++++++-- vendor/include/src/index.ts | 114 +++++---- vendor/loader/src/config/entry.ts | 192 +++++++++++--- vendor/loader/src/config/group.ts | 72 ++++-- vendor/loader/src/config/isolate.ts | 4 +- vendor/loader/src/config/tree.ts | 53 +++- vendor/loader/src/index.ts | 11 +- 33 files changed, 1020 insertions(+), 268 deletions(-) create mode 100644 packages/ui/app-boot/tests/hmr-config.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml index b16ef70d7c..f6a6429e64 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.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-config-hot-reload-resilience.md: 1a8e29c603ede50b60199e9151fca58dadcc3d40 -2026-07-20-config-hot-reload-resilience.zh.md: 6c7a421bfa84504a36d5329e13a485bf72cc6b6c +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md +2026-07-20-config-hot-reload-resilience.md: f3c36f8055179870c19c9d1ce99c3533fe602aa6 +2026-07-20-config-hot-reload-resilience.zh.md: 72ef2ebfa582dcc614198ed094c9b58ea1713460 diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md index 1a8e29c603..f3c36f8055 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md @@ -6,33 +6,36 @@ English | [中文](2026-07-20-config-hot-reload-resilience.zh.md) ## Problem -The demo apps mount `@cordisjs/plugin-hmr` as a leaf so a running agent picks up `cordis.yml` edits. One bad edit killed the process: `Include.refresh()` rethrew the YAML parse error, the HMR watcher awaits `refresh()` inside an async chokidar callback nobody catches, and the resulting unhandled rejection tripped `dsh-app-boot`'s fail-loud handler — `exit(1)` mid-session, losing the live TUI. Two adjacent defects made even *valid* reloads wrong: a file that parses to `undefined` (empty or mid-write truncated — editors and `sed -i` routinely produce these states) crashed the entry walk instead of reading as invalid, and a re-read never re-applied the include's `config.patches`, so any hot-reload of an overlay-based tree (Code Mode, personal overlays) silently reverted patched entries and removed inserted ones. +An invalid `cordis.yml` edit must not kill a running agent, but preserving the process is insufficient when a valid-looking update partially replaces the Loader tree before a later entry fails. Callers also need to observe a rejected live update without treating the same error as an unhandled boot failure. Personal configuration adds a second requirement: HMR must observe one exact file outside its module roots, including a file or parent directory created after startup. ## Decision -Harden the vendored `@cordisjs/plugin-include` (logged as local modification 8 in [vendor/README.md](../../../../vendor/README.md)) rather than the callers: +The vendored Cordis lifecycle and Loader plugins provide an awaited, compensating config transaction, logged as local modifications 6, 8, and 9 in [vendor/README.md](../../../../vendor/README.md). -- `refresh()` awaits the whole read-and-update and catches failures, logs a warning, and keeps the last good entry tree. A hot-reload is advisory; the invariant is that no file state reachable by an editor may take the process down. -- `read()` rejects a non-array parse result with a `TypeError`, folding the `undefined`-parse case into the same "invalid file" signal, and commits `content`/`data` only after a successful parse — so reverting an edit to the exact last good content correctly reads as "unchanged". -- `refresh()` and the `internal/update` listener apply `this.applyPatches(...)` before `root.update()`, restoring parity with `[Service.init]`. `applyPatches` deep-copies the cached parse (`structuredClone`) instead of mutating it, so repeated application converges and removing a patch reverts to the file's own values. The listener uses the incoming config's `patches` and persists that config itself: it vetoes the fiber restart (children update in place), and `Fiber.update` only assigns `this.config` behind `next()`, so without the explicit assignment the next re-read would re-apply the old overlay. +`Fiber.update()` returns its `internal/update` waterfall result. Config validation remains synchronous, while the default continuation returns the restart promise. Loader entry updates can therefore distinguish validation, import, application, and rollback failure from successful lifecycle settlement. `EntryTree.await()` rechecks service-gated fibers after Loader tasks drain and rejects settled failures; a fiber waiting on an absent service remains a valid pending entry rather than making settlement hang. -Boot-time behavior stays fail-loud and gets a sharper diagnostic: `[Service.init]` falls back to `initial` (or "config file not found") only on `ENOENT`; an existing-but-invalid file now fails with its real parse error instead of being mislabelled as absent or silently overwritten by `initial`. +Loader imports a changed module name before disposing the active fiber. Candidate application is awaited; a failure disposes candidate effects and restores the prior plugin or config. Group reconciliation is sequential and restores earlier changed entries, additions, removals, and moves before rejecting. Persistence occurs only after successful programmatic mutation. This is a compensating transaction: lifecycle effects may be briefly visible, and a failed rollback is reported as an `AggregateError` rather than misrepresented as a retained tree. + +Include reads and validates detached candidate content, applies patches to a clone, reconciles the Loader tree, and only then commits cached content and parsed data. `refresh()` rejects to its caller after a parse, validation, application, or rollback failure. Initial load remains fail-loud; only an absent file may use `initial`. A non-array YAML/JSON result is invalid, and both file refresh and Include-config update re-apply patches without mutating the cached parse. + +HMR contains live refresh rejection. Its `registerConfig(filename, refresh)` method watches one exact path from the nearest existing ancestor, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Both exact-path and ordinary config-file refreshes use that queue. A failure is normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed(filename, error)` event; rejecting observers are logged without stopping later refreshes. Creation, change, and removal are observed. ## Alternatives considered -**Catch in the HMR watcher callback instead of `refresh()`.** Rejected: it would leave `refresh()` a trap for every other caller (the `internal/update` path shares the same tree-update logic), and it cannot fix the `undefined`-parse or patch-loss defects, which live inside the include. +**Contain failures inside `Include.refresh()`.** Rejected because it prevents an HMR host from broadcasting the failure and still permits Loader reconciliation to hide partial application. Include owns candidate parsing and commit; HMR owns containment and observation. -**Filter config-file rejections in `installFailLoud`.** Rejected: the fail-loud handler exists to make late load failures visible; teaching it to classify exceptions by origin would silently swallow genuine boot failures and leave the stale-`data` crash in place. +**Restart the process for every config edit.** Rejected because Cordis effects already provide reversible plugin lifecycle, and a syntax error or failed optional plugin must not discard live sessions merely to recover the prior composition. -**A PTY e2e proving the TUI survives a bad edit.** Rejected as the primary gate: the PTY smoke reads the repo's committed `cordis.yml`, so corrupting it in-place is not test-safe, and a temp copy cannot resolve the tree's bare package specifiers. The unit spec drives the exact `refresh()` entry point the watcher calls; the fix was additionally verified manually against the live TUI (bad YAML, empty file, restored file). +**Promise invisible atomic replacement.** Rejected because arbitrary plugin effects cannot be snapshotted. Sequential application plus explicit compensation provides a stable final result without claiming that observers cannot see intermediate lifecycle transitions. ## Consequences -- A bad `cordis.yml` edit now logs `ignoring config reload at ` and the agent keeps running on the last good tree; the next valid edit applies normally. With no logger exporter mounted in the TUI demos the warning is currently invisible on screen — surfacing loader warnings in the TUI is deferred. -- Overlay trees survive base-file reloads with patches intact instead of silently reverting to the unpatched base. -- The vendored include diverges further from upstream; the divergence is logged in the vendor manifest and re-applies on the next sync. -- Known gap, out of scope here: the HMR watcher only handles chokidar `change` events, so editors that replace the file by rename (BSD `sed -i`, `git checkout`) do not trigger a config reload at all; and a reloaded app-entry config does not visibly restart the running TUI (pre-existing on the unmodified tree). +- A failed live refresh rejects internally, retains or restores the last-good tree when compensation succeeds, and broadcasts one typed failure without becoming an unhandled rejection. +- A rollback failure is visible and may leave an entry unavailable; the event and log do not claim otherwise. +- Fibers waiting on declared dependencies remain valid pending entries: lifecycle settlement means no current work failed, not that every dependency exists. +- Exact config watchers add filesystem resources only for registered paths and release them with their owning HMR fiber. +- The vendored Loader, Include, HMR, and core event typing diverge further from upstream; the complete divergence is maintained in the vendor manifest. ## Testing -`packages/ui/app-boot/tests/config-reload.spec.ts` boots real Loader trees against temp configs and pins: an invalid-YAML edit and an empty-file edit both resolve `refresh()` without rejection and keep the previous entry config; a subsequent valid edit applies; an overlay tree re-applies both entry patches and inserted entries on re-read; a hot-update of the include entry's own `patches` applies immediately, survives the next file re-read, and reverts cleanly when the patches are removed. The assertions fail on the unpatched vendored include. +`packages/ui/app-boot/tests/config-reload.spec.ts` boots real temporary Loader/Include trees and covers parse and shape rejection, import-before-dispose, plugin/config restoration, multi-entry rollback, ancestor disablement, overlay convergence, option identity, failed direct-update persistence, and failed programmatic moves. `packages/ui/app-boot/tests/hmr-config.spec.ts` covers existing and missing exact paths, add/change/removal, serialized coalescing, disposal drainage, non-`Error` normalization, failure broadcast, and rejecting-observer containment. `packages/host/webserver/tests/webserver.spec.ts` proves a service-gated startup failure rejects Loader composition with its bind diagnostic, and `packages/typert/loader/tests/loader.spec.ts` exercises awaited programmatic removal through a real Loader consumer. diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md index 6c7a421bfa..72ef2ebfa5 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md @@ -6,33 +6,36 @@ Status: implemented ## Problem -各示例应用把 `@cordisjs/plugin-hmr` 挂载为叶子配置项,让运行中的 agent 能感知 `cordis.yml` 的编辑。一次错误的编辑就会杀死进程:`Include.refresh()` 把 YAML 解析错误原样抛出,HMR 的文件监听器在一个无人捕获的异步 chokidar 回调里 await `refresh()`,产生的未处理 rejection 触发 `dsh-app-boot` 的快速失败处理器——会话中途 `exit(1)`,正在运行的 TUI 就此丢失。另有两个相邻缺陷让*合法*的重载也出错:解析结果为 `undefined` 的文件(空文件或写入中途被截断的文件——编辑器和 `sed -i` 常态性地产生这类中间状态)会让配置项遍历直接崩溃,而不是被判定为无效文件;并且重新读取时从不重新应用 include 的 `config.patches`,因此对基于 overlay 的配置树(Code Mode、个人 overlay)做任何热重载,都会悄悄把打过补丁的配置项回退、并把插入的配置项移除。 +无效的 `cordis.yml` 编辑不得杀死运行中的 agent(智能体);但若一次看似有效的更新先部分替换 Loader 树,后续配置项才失败,仅仅保住进程仍不够。调用方还需要能观察到被拒绝的实时更新,同时不能让同一个错误被当作未处理的启动失败。个人配置还带来第二项要求:HMR(热模块替换)必须观察其模块根目录之外的一个确切文件,包括启动后才创建的文件或父目录。 ## Decision -加固 vendor 的 `@cordisjs/plugin-include`(在 [vendor/README.md](../../../../vendor/README.md) 中记录为本地修改第 8 条),而不是修改调用方: +vendor 中的 Cordis 生命周期和 Loader 插件提供可等待、带补偿的配置事务,并在 [vendor/README.md](../../../../vendor/README.md) 中记录为本地修改第 6、8、9 条。 -- `refresh()` await 整个「读取并更新」过程并捕获失败,记录一条警告,并保留上一份完好的配置树。热重载是尽力而为的;不变式是编辑器可能产生的任何文件状态都不得导致进程退出。 -- `read()` 对非数组的解析结果抛出 `TypeError`,把 `undefined` 解析结果并入同一个「无效文件」信号,并且只在解析成功后才提交 `content`/`data`——因此把编辑撤销回与上一份完好内容完全一致时,会正确地判定为「无变化」。 -- `refresh()` 与 `internal/update` 监听器在 `root.update()` 之前调用 `this.applyPatches(...)`,与 `[Service.init]` 保持一致。`applyPatches` 对缓存的解析结果做深拷贝(`structuredClone`)而不是就地修改,因此重复应用会收敛,移除补丁会回退到文件自身的值。监听器使用传入配置中的 `patches` 并自行持久化该配置:它否决 fiber 重启(子配置项就地更新),而 `Fiber.update` 只在 `next()` 之后才赋值 `this.config`,若不显式赋值,下一次重新读取会重新应用旧的 overlay。 +`Fiber.update()` 返回其 `internal/update` waterfall(瀑布式事件)的结果。配置校验保持同步,而默认 continuation 返回重启 promise。因此,Loader 配置项更新可以区分校验、导入、应用和回滚失败,以及生命周期成功完成。`EntryTree.await()` 会在 Loader 任务排空后重新检查受服务门控的 fiber,并在 fiber 已结算为失败时 reject;等待缺失服务的 fiber 仍是有效的 pending 配置项,不会让结算挂起。 -启动期行为保持快速失败并获得更准确的诊断:`[Service.init]` 只在 `ENOENT` 时回退到 `initial`(或「config file not found」);存在但无效的文件现在会以真实的解析错误失败,而不是被误标为文件缺失、或被 `initial` 静默覆盖。 +Loader 会先导入变化后的模块名,再 dispose(资源释放)活动 fiber。它会 await 候选项的应用;若失败,则 dispose 候选项的 effect,并恢复先前的插件或配置。组内对账按顺序进行,并会在拒绝前恢复此前已变更的配置项、添加项、移除项和移动项。只有程序化变更成功后才会持久化。这是一种补偿事务:生命周期 effect 可能短暂可见;回滚失败会报告为 `AggregateError`,而不会被误称为树已保留。 + +Include 读取并校验尚未提交的候选内容,把补丁应用到其副本,对账 Loader 树,然后才提交缓存内容和解析数据。解析、校验、应用或回滚失败后,`refresh()` 会向调用方 reject。初始加载继续快速失败;只有文件不存在时才可以使用 `initial`。YAML/JSON 结果若不是数组即为无效;文件刷新和 Include 配置更新都会重新应用补丁,且不修改缓存的解析结果。 + +HMR 收容实时刷新 rejection。其 `registerConfig(filename, refresh)` 方法从最近的现有祖先目录开始监听一个确切路径,串行化并合并刷新,并返回一个异步 disposer;该 disposer 会关闭 watcher 并排空活跃工作。确切路径和普通配置文件的刷新都使用此队列。失败会被规范化为 `Error`、记入日志,并通过并行事件 `hmr/config-update-failed(filename, error)` 广播;发生 rejection 的观察者会被记录,但不会阻止后续刷新。创建、变更和移除均会被观察。 ## Alternatives considered -**在 HMR 监听回调里捕获,而不是在 `refresh()` 里。** 否决:这会让 `refresh()` 继续成为其他所有调用方的陷阱(`internal/update` 路径共享同一套树更新逻辑),而且无法修复 `undefined` 解析结果与补丁丢失这两个位于 include 内部的缺陷。 +**在 `Include.refresh()` 内收容失败。** 已否决,因为这会使 HMR 宿主无法广播失败,却仍允许 Loader 对账掩盖部分应用。Include 负责候选内容的解析与提交;HMR 负责收容和观察。 -**在 `installFailLoud` 里过滤配置文件相关的 rejection。** 否决:快速失败处理器的存在意义就是让延迟出现的加载失败可见;教它按来源给异常分类会悄悄吞掉真正的启动失败,并且原样保留陈旧 `data` 导致的崩溃。 +**每次编辑配置都重启进程。** 已否决,因为 Cordis effect 已经提供可逆的插件生命周期,而语法错误或可选插件失败不应只为恢复先前的组合就丢弃正在进行的会话。 -**用 PTY e2e 证明 TUI 能在错误编辑后存活。** 否决其作为主要门禁:PTY 冒烟测试读取仓库中已提交的 `cordis.yml`,就地破坏它对测试不安全,而临时副本无法解析该配置树的裸包说明符。单元测试直接驱动监听器所调用的 `refresh()` 入口;此外还对运行中的 TUI 做了人工验证(错误 YAML、空文件、恢复文件)。 +**承诺不可见的原子替换。** 已否决,因为任意插件 effect 无法制作快照。按顺序应用并显式补偿可以得到稳定的最终结果,同时不会声称观察者看不到中间生命周期转换。 ## Consequences -- 现在错误的 `cordis.yml` 编辑会记录 `ignoring config reload at `,agent 继续运行在上一份完好的配置树上;下一次合法编辑正常生效。TUI 示例没有挂载任何日志导出器,这条警告目前不会显示在屏幕上——在 TUI 中呈现 loader 警告的工作暂缓。 -- overlay 配置树在基础文件重载后补丁保持完整,不再悄悄回退到未打补丁的基础配置。 -- vendor 的 include 与上游进一步分叉;该分叉已记录在 vendor 的 manifest 里,下次同步时重新应用。 -- 已知缺口,不在本次范围内:HMR 监听器只处理 chokidar 的 `change` 事件,因此通过重命名替换文件的编辑方式(BSD `sed -i`、`git checkout`)完全不会触发配置重载;应用配置项重载后也不会可见地重启运行中的 TUI(未修改的代码树上即已如此)。 +- 实时刷新失败会在内部 reject;补偿成功时会保留或恢复上一份完好的树,并广播一次类型化失败,而不会成为未处理的 rejection。 +- 回滚失败可见,并可能使一个配置项不可用;事件和日志不会误称其已恢复。 +- 等待已声明依赖的 fiber 仍是有效的 pending 配置项:生命周期完成只表示当前工作均未失败,而不表示每项依赖都存在。 +- 确切配置 watcher 只为已注册路径增加文件系统资源,并随其所属 HMR fiber 一起释放。 +- vendor 中的 Loader、Include、HMR 与核心事件类型定义进一步偏离上游;全部分叉均维护在 vendor manifest(元数据清单)中。 ## Testing -`packages/ui/app-boot/tests/config-reload.spec.ts` 用真实 Loader 树加载临时配置并固定以下行为:无效 YAML 编辑和空文件编辑都让 `refresh()` 正常 resolve 而不产生 rejection,并保留之前的配置项配置;随后的合法编辑正常生效;overlay 配置树在重新读取时重新应用配置项补丁和插入的配置项;对 include 配置项自身 `patches` 的热更新立即生效、在下一次文件重读后依然保持、并在补丁移除后干净地回退。这些断言在未打补丁的 vendor include 上会失败。 +`packages/ui/app-boot/tests/config-reload.spec.ts` 启动真实的临时 Loader/Include 树,并覆盖对解析和形状错误的拒绝、先导入再 dispose、插件/配置恢复、多配置项回滚、祖先禁用、overlay 收敛、option 对象身份、失败的直接更新不持久化以及失败的程序化移动。`packages/ui/app-boot/tests/hmr-config.spec.ts` 覆盖现有和缺失的确切路径、添加/变更/移除、串行化合并、dispose 排空、非 `Error` 值的规范化、失败广播以及对发生 rejection 的观察者的收容。`packages/host/webserver/tests/webserver.spec.ts` 证明受服务门控的启动失败会让 Loader 组合以其 bind 诊断 reject;`packages/typert/loader/tests/loader.spec.ts` 则通过真实 Loader 消费方演练可等待的程序化移除。 diff --git a/docs/cordis-catalog/core/fiber.md b/docs/cordis-catalog/core/fiber.md index 3cca4e8b86..35a991f789 100644 --- a/docs/cordis-catalog/core/fiber.md +++ b/docs/cordis-catalog/core/fiber.md @@ -256,8 +256,8 @@ Dispose and immediately reload this plugin with its current config. * * @param config — the new raw config; validated before anything restarts. * @param noSave — hint for persistence hooks not to write the change back. - * @returns nothing; the restart runs behind the `internal/update` waterfall. - * @throws {ValidationError} when the new config fails validation. + * @returns the update waterfall result; the default restart returns a promise. + * @throws when validation, an update listener, or the restarted plugin fails. */ update(config: any, noSave = false) ``` @@ -269,7 +269,7 @@ Runs the `internal/update` waterfall first, so update hooks (and HMR) can veto o - `config` — the new raw config; validated before anything restarts. - `noSave` — hint for persistence hooks not to write the change back. -**Returns** nothing; the restart runs behind the `internal/update` waterfall. +**Returns** the update waterfall result; the default restart returns a promise. [Source](../../../vendor/cordis/src/fiber.ts#L734) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index a7ff21e2f4..1f9386fe39 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -1183,7 +1183,8 @@ The framework events every plugin also sees, beyond the harness vocabulary above - `internal/listener` — A listener was registered. ([`vendor/cordis/src/events.ts:340`](../../vendor/cordis/src/events.ts)) - `internal/dispatch` — An event is being dispatched to listeners. ([`vendor/cordis/src/events.ts:342`](../../vendor/cordis/src/events.ts)) - `hmr/change` — A watched source file changed on disk. ([`vendor/hmr/src/index.ts:20`](../../vendor/hmr/src/index.ts)) -- `hmr/reload` — Plugins are being reloaded after a change. ([`vendor/hmr/src/index.ts:21`](../../vendor/hmr/src/index.ts)) +- `hmr/reload` — Plugins are being reloaded after a change. ([`vendor/hmr/src/index.ts:22`](../../vendor/hmr/src/index.ts)) +- `hmr/config-update-failed` — A watched config-file refresh failed. ([`vendor/hmr/src/index.ts:29`](../../vendor/hmr/src/index.ts)) - `exit` — The process is exiting on a signal. ([`vendor/loader/src/index.ts:23`](../../vendor/loader/src/index.ts)) - `loader/config-update` — The loader config tree changed. ([`vendor/loader/src/index.ts:24`](../../vendor/loader/src/index.ts)) - `loader/entry-init` — A config entry is being initialized. ([`vendor/loader/src/index.ts:25`](../../vendor/loader/src/index.ts)) diff --git a/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt b/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt index 5896d03464..cd688cd471 100644 --- a/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt +++ b/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt @@ -1,3 +1,3 @@ -dsh-cli-demo: dsh-cli-demo: 1 entry did not activate -./activation-error.mjs: Error: startup activation snapshot failure +dsh-cli-demo: dsh-cli-demo: plugin tree failed to load: failed to apply loader entry include (cordis:include): failed to apply loader entry activation-error (./activation-error.mjs): startup activation snapshot failure +Error: startup activation snapshot failure at activation-error-fixture diff --git a/packages/host/directory-picker-auto/src/index.ts b/packages/host/directory-picker-auto/src/index.ts index 5766e36b98..3cf75b20dc 100644 --- a/packages/host/directory-picker-auto/src/index.ts +++ b/packages/host/directory-picker-auto/src/index.ts @@ -61,11 +61,9 @@ export async function apply(ctx: Context): Promise { // nothing is left to unmount or await then. const entry = ctx.loader.store[id] if (entry === undefined) return - const fiber = entry.fiber - ctx.loader.remove(id) - // remove() only starts the fiber's dispose; join it so the chooser's - // unload signals completion only after the backend quiesced. - await fiber?.dispose() + // remove() disposes the entry transactionally, so the chooser's unload + // signals completion only after the backend quiesced. + await ctx.loader.remove(id) } }, 'directory-picker-auto: backend entry') } diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts index 59ca1c1992..9d0b8c7de8 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -167,7 +167,7 @@ describe('real Loader composition', () => { const { ctx, configPath } = await loadComposition('127.0.0.1') const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)! - ctx.loader.remove(backendEntry.id) + await ctx.loader.remove(backendEntry.id) const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)! await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow() expect(entryNames(ctx)).not.toContain(NATIVE) diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index 0160db9f01..a79958e9d2 100644 --- a/packages/host/webserver/README.i18n.yaml +++ b/packages/host/webserver/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/webserver/README.md -README.md: ace8c09e43dd8544a28d300f97b04610be78bc69 -README.zh.md: b9948e3d387a5da393ff62b9eeacfe310516f46a +README.md: c3c7b222683bc7731a6c21f2fffd325225099bab +README.zh.md: 99c0560eb74dc8076772ba1deef3034000f5f0db diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index ace8c09e43..c3c7b22268 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -6,7 +6,7 @@ Plain HTTP route-registration plugin (default-exported `HttpServerService`, conf The package knows no harness concepts: the `/api` bridge is the connection plugin's route, plugin bundles and the HMR event stream are the modules/hmr plugins' routes. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. -A listen failure (EADDRINUSE…) throws out of activation — a FAILED fiber the boot's fail-loud sweep reports. A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. Disposal pairs `close()` with `closeAllConnections()` because held-open responses (SSE) never end on their own. +A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. Disposal pairs `close()` with `closeAllConnections()` because held-open responses (SSE) never end on their own. In development, the client-plugin registry synchronously captures each built bundle's stat baseline before it returns, then polls those baselines and re-hashes changed content. Each rescan stages its candidate table, graph, and watch map before publishing them, so a baseline failure preserves the prior graph. An immediate rebuild therefore cannot disappear into an asynchronously established watch baseline; a rename window marks the path dirty, retains the last successful baseline, and forces a re-hash when the bundle reappears even with identical metadata. diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index b9948e3d38..99c0560eb7 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -6,7 +6,7 @@ 该包不了解任何 harness 概念:`/api` 桥接是 connection 插件的路由,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的路由。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。 -监听失败(EADDRINUSE……)会从激活过程抛出,使 fiber 进入 FAILED 状态并由启动流程的快速失败扫描报告。处理请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。资源释放会把 `close()` 与 `closeAllConnections()` 配对,因为一直保持打开的响应(SSE)不会自行结束。 +监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。资源释放会把 `close()` 与 `closeAllConnections()` 配对,因为一直保持打开的 SSE(Server-Sent Events)响应不会自行结束。 在开发环境中,客户端插件注册表会在返回前同步捕获每个已构建 bundle 的 stat 基线,随后轮询这些基线,并在内容变化后重新计算哈希。每次重新扫描都会先暂存候选表、图和监听 map,再统一发布,因此基线失败会保留先前的图。这样,即时重建不会消失在异步建立的监听基线中;重命名窗口会把路径标记为脏,保留最近一次成功基线,并在 bundle 重新出现时强制重新计算哈希,即使其元数据完全相同也不例外。 diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index 017fedba1a..c64208eb8e 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -11,7 +11,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { Context, FiberState } from 'cordis' +import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import HttpServer from '../src/index.ts' @@ -142,25 +142,17 @@ describe('real Loader composition', () => { const firstRoot = root root = undefined // keep the first composition's files until the end - // loader.await() never rejects (allSettled); the bind failure surfaces as - // a FAILED fiber whose error escapes as a late rejection — the shape the - // boot's installFailLoud is contracted to catch. Capture it here the same - // way, and assert it really is the bind error. - const rejections: unknown[] = [] - const onUnhandled = (err: unknown): void => { rejections.push(err) } - process.on('unhandledRejection', onUnhandled) let second: Context | undefined try { - second = await loadComposition(takenPort) - const entry = [...second.loader.entries()].find(e => e.options.name === '@deepseek-ai/dsh-host-webserver') - expect(entry?.fiber?.state).toBe(FiberState.FAILED) - // The rejection escapes a tick after loader.await() settles; bounded poll. - for (let i = 0; i < 100 && rejections.length === 0; i++) { - await new Promise(resolve => setTimeout(resolve, 10)) + let failure: unknown + try { + await loadComposition(takenPort) + } catch (error) { + failure = error } - expect(rejections.map(String).join('\n')).toContain('EADDRINUSE') + second = context + expect(String(failure)).toMatch(/failed to apply loader entry.*EADDRINUSE/) } finally { - process.off('unhandledRejection', onUnhandled) await second?.fiber.dispose() context = first if (root !== undefined) await rm(root, { recursive: true, force: true }) diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index c61db1ae9c..3b126f1e76 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -147,12 +147,12 @@ describe('typert loader', () => { await new Promise(resolve => setTimeout(resolve, 20)) expect(ctx.typert.list()).toHaveLength(1) - ctx.loader.remove(id) + await ctx.loader.remove(id) await ctx.loader.await() // The unmount reconciliation rides a queued microtask flush. await new Promise(resolve => setTimeout(resolve, 20)) expect(ctx.typert.get('@fixture/with-typert#Thing')).toBeUndefined() - ctx.loader.remove(plainId) + await ctx.loader.remove(plainId) await ctx.loader.await() await new Promise(resolve => setTimeout(resolve, 20)) diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 0ee7591bec..6d7aa6f0d3 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: efc8c42e192a02ecf37f8ea1356aa22261c70d0e -README.zh.md: 927d6d1fb493c404fcdbe14f1c668b1743412ea5 +README.md: e82d378f9cabd24d0f8b3069237f142c1885191f +README.zh.md: 5d749531e48a502291491e6d047b45cd8a505544 diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index efc8c42e19..e82d378f9c 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -8,17 +8,17 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` | | `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) | +| `installFailLoud(binName, proc?)` | Turn an unhandled boot or later 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 | | `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | | `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 | | `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, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, 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 activated, and return the root context | +| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, 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 activated, and return the root context — or dispose the partial context and reject a labelled error | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of same-provenance rows is preceded by a `# ==` comment naming the contributing file and the layers that patched it, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw | | `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 the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; 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 | -Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal. +Loader settlement rejects import and lifecycle failures with the failing entry and stage; `boot()` disposes the partial context and wraps that failure with the bin name. Entries settlement leaves behind are audited separately: `assertEntriesLoaded` turns an enabled fiber-less entry into a rejection naming every unresolved plugin, and `assertEntriesActivated` awaits each failed fiber to include its original stack in the startup rejection and names each pending entry's unresolved services. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal. Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index 927d6d1fb4..5d749531e4 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -8,17 +8,17 @@ |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | 生成绝对配置路径;当 `snapshotMode === 'replay'` 时,把 basename 为 `cordis.yml`/`.yaml` 的文件替换为同级 `cordis.snapshot.yml` | | `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) | -| `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) | +| `installFailLoud(binName, proc?)` | 将启动期或后续未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) | | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | | `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | | `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | | `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 | -| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文 | +| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献该段的文件以及修补过它的层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | -Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。 +Loader 结算会在导入或生命周期失败时 reject,并携带失败的配置项与阶段;`boot()` 会 dispose 部分构造的上下文,并用 bin 名称包装该失败。结算后遗留的配置项由独立审计处理:`assertEntriesLoaded` 将已启用却没有 fiber 的配置项转换为 rejection 并列出每个未解析插件;`assertEntriesActivated` 会显式等待每个失败的 fiber,把原始错误堆栈写入启动 rejection,并列出每个等待中配置项尚未解析的服务。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。 配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。 diff --git a/packages/ui/app-boot/package.json b/packages/ui/app-boot/package.json index ef267e8588..68062cb480 100644 --- a/packages/ui/app-boot/package.json +++ b/packages/ui/app-boot/package.json @@ -38,8 +38,10 @@ "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@cordisjs/plugin-hmr": "workspace:^", "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", + "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index febd724097..917eb777e8 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs' import { basename, dirname, join, resolve } from 'node:path' import * as yaml from 'js-yaml' import { Context, type FiberState } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import Loader, { type EntryOptions } from '@cordisjs/plugin-loader' import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths' // Side-effect type import: resolves `ctx.get('systemPrompt')` to the service. @@ -430,12 +430,13 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro * `cordis:include` builtin, loading through the ambient module pipeline * (vite/tsx/plain ESM) while the included tree's own specifiers stay * config-relative. The package build embeds Include while leaving Loader - * external, so the built include tree and host share one Loader peer. A - * missing fiber rejects here; a later init rejection is rethrown with its - * original stack by {@link assertEntriesActivated}; later unhandled - * rejections remain covered by {@link installFailLoud}. Built bins need the - * Loader's native helper for bare plugin specifiers; relative specifiers do - * not. + * external, so the built include tree and host share one Loader peer. Loader + * settlement rejects startup failures, which `boot` wraps after disposing the + * partial context; a missing fiber or never-activating entry is rejected by + * the final audit, {@link assertEntriesActivated}, which rethrows a plugin's + * init rejection with its original stack; later unhandled rejections remain + * covered by {@link installFailLoud}. Built bins need the Loader's native + * helper for bare plugin specifiers; relative specifiers do not. * @param binName - the diagnostic prefix for load-failure errors. * @param absoluteConfigPath - the config to include; must already be absolute * (see {@link resolveConfigPath}). @@ -444,6 +445,7 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro * @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, or as soon as a * surface disposed the tree while startup was still in flight. + * @throws a labelled load error after disposing the partial context. */ export async function boot( binName: string, @@ -452,28 +454,49 @@ export async function boot( prepare?: (ctx: Context) => Promise | void, ): Promise { const ctx = new Context() - ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' - ctx.provide('dshHomePath', dshHomePath) - await ctx.plugin(Loader) - ctx.loader.builtins.include = Include - await prepare?.(ctx) - await ctx.loader.create({ - name: 'cordis:include', - config: { - path: pathToFileURL(absoluteConfigPath).href, - ...patches !== undefined && patches.length > 0 ? { patches } : {}, - }, - }) - await ctx.loader.await() - // A surface can finish and dispose the whole tree while that await is still - // pending: the TUI renders as soon as its own fiber starts, so an `/exit` - // typed before the last entry settles tears the context down under us. The - // Loader service goes with it, and the activation audit describes a live - // tree — reading `ctx.loader` here would throw a TypeError over an app that - // exited exactly as asked. - if (ctx.get('loader') === undefined) return ctx - await assertEntriesActivated(ctx, binName) - return ctx + try { + ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' + ctx.provide('dshHomePath', dshHomePath) + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await prepare?.(ctx) + // Pinned id: the bootstrap include is app glue, not a config row, and its + // id appears in Loader failure chains — a random id would make startup + // diagnostics unstable across runs (and snapshot fixtures). + const rootInclude: EntryOptions = { + id: 'include', + name: 'cordis:include', + config: { + path: pathToFileURL(absoluteConfigPath).href, + ...patches !== undefined && patches.length > 0 ? { patches } : {}, + }, + } + await ctx.loader.create(rootInclude) + // A surface can finish and dispose the whole tree while startup is still + // in flight: the TUI renders as soon as its own fiber starts, so an `/exit` + // typed before the last entry settles tears the context down under us. The + // Loader service goes with it, and the activation audit describes a live + // tree — reading `ctx.loader` past this point would throw a TypeError over + // an app that exited exactly as asked. Transactional group updates settle + // lifecycle inside the mount, so the teardown can land before it returns; + // re-check after every await. + await ctx.get('loader')?.await() + if (ctx.get('loader') === undefined) return ctx + await assertEntriesActivated(ctx, binName) + return ctx + } catch (cause) { + await ctx.fiber.dispose() + const detail = cause instanceof Error ? cause.message : String(cause) + // The transactional Loader wraps a failing entry apply in one message per + // tree layer; every layer's message is folded into `detail` above, and the + // deepest cause is the plugin's own thrown error, whose stack names the + // real failure site — append it so the startup diagnostic preserves the + // original activation error instead of only the wrap chain. + let deepest: unknown = cause + while (deepest instanceof Error && deepest.cause !== undefined) deepest = deepest.cause + const stack = deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : '' + throw new Error(`${binName}: plugin tree failed to load: ${detail}${stack}`, { cause }) + } } /** Prompt-section name for the harness-source location line an app bin adds after boot. */ diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 4f64365a64..03dd93a365 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -325,6 +325,22 @@ describe('boot', () => { } }) + it('disposes partial host setup and labels non-Error preparation failures', async () => { + const dir = tmp() + const failure = 42 + let disposed = false + const task = boot(NAME, join(dir, 'cordis.yml'), undefined, (ctx) => { + ctx.effect(() => () => { disposed = true }) + throw failure + }) + + await expect(task).rejects.toMatchObject({ + message: `${NAME}: plugin tree failed to load: ${failure}`, + cause: failure, + }) + expect(disposed).toBe(true) + }) + it('exposes dshHomePath to Loader config expressions', async () => { const dir = tmp() const dshHome = join(dir, 'home') @@ -375,7 +391,37 @@ describe('boot', () => { it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => { const dir = tmp() 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`) + await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow( + `${NAME}: plugin tree failed to load: failed to apply loader entry`, + ) + }) + + it('appends the deepest cause with its original stack to the load failure', async () => { + const dir = tmp() + writeFileSync(join(dir, 'failing.mjs'), [ + 'export function apply() {', + " const failure = new Error('pinned activation failure')", + " failure.stack = 'Error: pinned activation failure\\n at failing-fixture'", + ' throw failure', + '}', + '', + ].join('\n')) + writeFileSync(join(dir, 'cordis.yml'), '- id: failing\n name: ./failing.mjs\n') + await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(new RegExp([ + String.raw`failed to apply loader entry failing \(\./failing\.mjs\): pinned activation failure\n`, + String.raw`Error: pinned activation failure\n {4}at failing-fixture$`, + ].join(''))) + }) + + it('falls back to the deepest cause message when its stack was erased', async () => { + const dir = tmp() + const deepest = new Error('stackless deep failure') + delete (deepest as { stack?: string }).stack + await expect(boot(NAME, join(dir, 'cordis.yml'), undefined, () => { + throw new Error('host preparation failed', { cause: deepest }) + })).rejects.toThrow( + `${NAME}: plugin tree failed to load: host preparation failed\nstackless deep failure`, + ) }) it('reports a pending real Loader fiber and the service unresolved in its own context', async () => { diff --git a/packages/ui/app-boot/tests/config-reload.spec.ts b/packages/ui/app-boot/tests/config-reload.spec.ts index c4f6c48d7f..1a236e906b 100644 --- a/packages/ui/app-boot/tests/config-reload.spec.ts +++ b/packages/ui/app-boot/tests/config-reload.spec.ts @@ -1,12 +1,7 @@ /** - * Config hot-reload resilience of the booted include tree. `dsh-app-boot` - * installs a fail-loud unhandled-rejection handler, so a `refresh()` that - * rethrows a config-file parse error would kill a live app on one bad - * `cordis.yml` edit (the HMR watcher awaits `refresh()` in an async event - * callback nobody else catches). These tests pin the vendored - * `@cordisjs/plugin-include` contract that boot relies on: an invalid file - * keeps the last good tree, and a valid re-read re-applies overlay patches - * exactly like the initial load. + * Transactional config replacement through the booted Include and Loader tree. + * HMR contains rejected refreshes; direct callers receive the error after the + * previous generation has been retained or restored. */ import { mkdtempSync, writeFileSync } from 'node:fs' @@ -15,6 +10,7 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import type { Context } from 'cordis' import type { Include } from '@cordisjs/plugin-include' +import { Group } from '@cordisjs/plugin-loader' import { boot } from '../src/index.ts' const NAME = 'dsh-test-bin' @@ -27,9 +23,10 @@ interface TreeFixture { include: Include } -async function bootTree(configBody: string): Promise { +async function bootTree(configBody: string, files: Record = {}): Promise { const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-')) writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN) + for (const [name, content] of Object.entries(files)) writeFileSync(join(dir, name), content) writeFileSync(join(dir, 'cordis.yml'), configBody) const ctx = await boot(NAME, join(dir, 'cordis.yml')) const entry = [...ctx.loader.entries()].find(candidate => candidate.subtree !== undefined) @@ -41,20 +38,41 @@ function entryConfig(ctx: Context, id: string): unknown { return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config } +function entryById(ctx: Context, id: string) { + const entry = [...ctx.loader.entries()].find(entry => entry.options.id === id) + if (!entry) throw new Error(`missing loader entry ${id}`) + return entry +} + +function plugin(name: string, body = ''): string { + return `export default function ${name}(_ctx, config = {}) { ${body} }\n` +} + +async function expectUpdateFailure(task: Promise, stage: string): Promise { + try { + await task + } catch (error) { + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toContain(`failed to ${stage} loader entry`) + return + } + throw new Error(`expected loader update to fail during ${stage}`) +} + describe('include refresh with an invalid file', () => { - it('keeps the last good tree instead of throwing, then applies the next valid edit', async () => { + it('rejects while keeping the last good tree, then applies the next valid edit', async () => { const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n config:\n value: 1\n') try { expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) writeFileSync(join(dir, 'cordis.yml'), 'invalid: [unclosed\n') - await expect(include.refresh()).resolves.toBeUndefined() + await expect(include.refresh()).rejects.toThrow('failed to parse config file') expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) // An empty file parses to `undefined` without a YAML error; it must be // treated exactly like a parse failure, not crash the entry walk. writeFileSync(join(dir, 'cordis.yml'), '') - await expect(include.refresh()).resolves.toBeUndefined() + await expect(include.refresh()).rejects.toThrow('failed to validate config file') expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: 2\n') @@ -67,6 +85,200 @@ describe('include refresh with an invalid file', () => { }) }) +describe('loader entry replacement', () => { + it('imports a changed name before replacing the running plugin', async () => { + const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', { + 'old.mjs': plugin('oldPlugin'), + 'new.mjs': plugin('newPlugin'), + }) + try { + const entry = entryById(ctx, 'target') + await entry.update({ name: './new.mjs' }) + expect(entry.options.name).toBe('./new.mjs') + expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options) + expect(entry.fiber?.runtime?.callback.name).toBe('newPlugin') + expect(entry.options.disabled).toBeUndefined() + await entry.fiber?.await() + } finally { + await ctx.fiber.dispose() + } + }) + + it('retains the running plugin when the replacement cannot be imported', async () => { + const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', { + 'old.mjs': plugin('oldPlugin'), + }) + try { + const entry = entryById(ctx, 'target') + const fiber = entry.fiber + await expectUpdateFailure(entry.update({ name: './missing.mjs' }), 'import') + expect(entry.options.name).toBe('./old.mjs') + expect(entry.fiber === fiber).toBe(true) + await fiber?.await() + } finally { + await ctx.fiber.dispose() + } + }) + + it('restores the previous plugin after replacement application fails', async () => { + const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', { + 'old.mjs': plugin('oldPlugin'), + 'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'), + }) + try { + const entry = entryById(ctx, 'target') + const previous = entry.fiber + await expectUpdateFailure(entry.update({ name: './bad.mjs' }), 'apply') + expect(entry.options.name).toBe('./old.mjs') + expect(entry.fiber === previous).toBe(false) + expect(entry.fiber?.runtime?.callback.name).toBe('oldPlugin') + expect(entry.options.disabled).toBeUndefined() + await entry.fiber?.await() + } finally { + await ctx.fiber.dispose() + } + }) + + it('restores the previous config when an in-place restart fails', async () => { + const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', { + 'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'), + }) + try { + const entry = entryById(ctx, 'target') + const fiber = entry.fiber + await expectUpdateFailure(entry.update({ config: { fail: true } }), 'apply') + expect(entry.options.config).toEqual({ fail: false }) + expect(entry.fiber === fiber).toBe(true) + await fiber?.await() + } finally { + await ctx.fiber.dispose() + } + }) + + it('does not persist a failed direct fiber update', async () => { + const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', { + 'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'), + }) + try { + const entry = entryById(ctx, 'target') + const fiber = entry.fiber + if (!fiber) throw new Error('target entry has no fiber') + await expect(fiber.update({ fail: true })).rejects.toThrow('candidate config failed') + expect(entry.options.config).toEqual({ fail: false }) + expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options) + } finally { + await ctx.fiber.dispose() + } + }) +}) + +describe('loader tree replacement', () => { + it('rolls back earlier updates and additions when a later entry fails', async () => { + const { ctx, dir, include } = await bootTree([ + '- id: existing', + ' name: ./configurable.mjs', + ' config:', + ' value: old', + '', + ].join('\n'), { + 'configurable.mjs': plugin('configurablePlugin'), + 'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'), + }) + try { + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: existing', + ' name: ./configurable.mjs', + ' config:', + ' value: candidate', + '- id: added', + ' name: ./noop.mjs', + '- id: bad', + ' name: ./bad.mjs', + '', + ].join('\n')) + await expect(include.refresh()).rejects.toThrow('failed to apply loader entry bad') + expect(entryConfig(ctx, 'existing')).toEqual({ value: 'old' }) + expect([...ctx.loader.entries()].some(entry => entry.options.id === 'added')).toBe(false) + expect([...ctx.loader.entries()].some(entry => entry.options.id === 'bad')).toBe(false) + + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: existing', + ' name: ./configurable.mjs', + ' config:', + ' value: committed', + '- id: added', + ' name: ./noop.mjs', + '', + ].join('\n')) + await include.refresh() + expect(entryConfig(ctx, 'existing')).toEqual({ value: 'committed' }) + expect(entryById(ctx, 'added').fiber).toBeDefined() + } finally { + await ctx.fiber.dispose() + } + }) + + it('stops and restores descendants when an ancestor group is disabled and re-enabled', async () => { + const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n') + ctx.loader.builtins.group = Group + try { + const config = (disabled: boolean) => [ + '- id: parent', + ' name: cordis:group', + ' group: true', + ` disabled: ${disabled}`, + ' config:', + ' - id: child', + ' name: ./noop.mjs', + '', + ].join('\n') + + writeFileSync(join(dir, 'cordis.yml'), config(false)) + await include.refresh() + expect(entryById(ctx, 'child').fiber).toBeDefined() + + writeFileSync(join(dir, 'cordis.yml'), config(true)) + await include.refresh() + expect(entryById(ctx, 'child').fiber).toBeUndefined() + + writeFileSync(join(dir, 'cordis.yml'), config(false)) + await include.refresh() + expect(entryById(ctx, 'child').fiber).toBeDefined() + } finally { + await ctx.fiber.dispose() + } + }) + + it('restores a programmatic entry move when its update fails', async () => { + const { ctx } = await bootTree('- id: noop\n name: ./noop.mjs\n', { + 'movable.mjs': plugin('movablePlugin', 'if (config.fail) throw new Error("candidate config failed")'), + }) + ctx.loader.builtins.group = Group + try { + const groupId = await ctx.loader.create({ name: 'cordis:group', group: true, config: [] }) + const targetId = await ctx.loader.create({ name: './movable.mjs', config: { fail: false } }) + const target = entryById(ctx, targetId) + const source = target.parent + const sourceIndex = source.data.indexOf(target.options) + const destination = entryById(ctx, groupId).subgroup + if (!destination) throw new Error('created loader group has no subgroup') + + await expectUpdateFailure( + ctx.loader.update(targetId, { config: { fail: true } }, groupId), + 'apply', + ) + + expect(target.parent).toBe(source) + expect(Object.getPrototypeOf(target.ctx)).toBe(source.ctx) + expect(source.data.indexOf(target.options)).toBe(sourceIndex) + expect(destination.data).not.toContain(target.options) + expect(target.options.config).toEqual({ fail: false }) + } finally { + await ctx.fiber.dispose() + } + }) +}) + describe('include refresh with overlay patches', () => { it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-')) diff --git a/packages/ui/app-boot/tests/hmr-config.spec.ts b/packages/ui/app-boot/tests/hmr-config.spec.ts new file mode 100644 index 0000000000..1892a6e73a --- /dev/null +++ b/packages/ui/app-boot/tests/hmr-config.spec.ts @@ -0,0 +1,142 @@ +import { mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import Hmr from '@cordisjs/plugin-hmr' +import Loader from '@cordisjs/plugin-loader' +import Timer from '@cordisjs/plugin-timer' +import { describe, expect, it } from 'vitest' + +async function bootHmr(dir: string): Promise { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(dir).href + '/' + await ctx.plugin(Loader) + await ctx.plugin(Timer) + await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + return ctx +} + +async function eventually(test: () => boolean, message: string): Promise { + const deadline = Date.now() + 10_000 + while (!test()) { + if (Date.now() >= deadline) throw new Error(message) + await new Promise(resolve => setTimeout(resolve, 10)) + } +} + +describe('HMR exact config paths', () => { + it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + const filename = join(dir, 'plugins.yml') + const ctx = await bootHmr(dir) + const observed: string[] = [] + try { + await ctx.hmr.registerConfig(filename, () => { + try { + observed.push(readFileSync(filename, 'utf8')) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + observed.push('missing') + } + }) + + writeFileSync(filename, 'one', { flag: 'wx' }) + await eventually(() => observed.includes('one'), 'HMR did not observe config creation') + writeFileSync(filename, 'two') + await eventually(() => observed.includes('two'), 'HMR did not observe config change') + unlinkSync(filename) + await eventually(() => observed.includes('missing'), 'HMR did not observe config removal') + } finally { + await ctx.fiber.dispose() + } + }) + + it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + const dir = join(root, 'later') + const filename = join(dir, 'plugins.yml') + const ctx = await bootHmr(root) + const observed: string[] = [] + try { + await ctx.hmr.registerConfig(filename, () => { + observed.push(readFileSync(filename, 'utf8')) + }) + mkdirSync(dir) + writeFileSync(filename, 'created') + await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent') + } finally { + await ctx.fiber.dispose() + } + }) + + it('serializes refreshes and waits for them during disposal', { timeout: 20_000 }, async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + const filename = join(dir, 'plugins.yml') + writeFileSync(filename, 'one') + const ctx = await bootHmr(dir) + const started = Promise.withResolvers() + const release = Promise.withResolvers() + const observed: string[] = [] + let active = 0 + let maxActive = 0 + try { + const dispose = await ctx.hmr.registerConfig(filename, async () => { + active += 1 + maxActive = Math.max(maxActive, active) + observed.push(readFileSync(filename, 'utf8')) + if (observed.length === 1) { + started.resolve(undefined) + await release.promise + } + active -= 1 + }) + await started.promise + writeFileSync(filename, 'two') + // Chokidar coalesces atomic writes for 100 ms by default. Wait beyond + // that window so this edit is queued before registration disposal. + await new Promise(resolve => setTimeout(resolve, 250)) + + let disposed = false + const disposal = dispose().then(() => { disposed = true }) + await Promise.resolve() + expect(disposed).toBe(false) + release.resolve(undefined) + await disposal + expect(maxActive).toBe(1) + expect(observed).toEqual(['one', 'two']) + } finally { + release.resolve(undefined) + await ctx.fiber.dispose() + } + }) + + it('normalizes refresh failures and broadcasts them without escaping the watcher', { timeout: 20_000 }, async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + const filename = join(dir, 'plugins.yml') + const ctx = await bootHmr(dir) + const failure = Promise.withResolvers<{ filename: string; error: Error }>() + let failureCount = 0 + try { + ctx.on('hmr/config-update-failed', () => { + throw new Error('observer failed') + }) + ctx.on('hmr/config-update-failed', (failedFilename, error) => { + failureCount += 1 + failure.resolve({ filename: failedFilename, error }) + }) + await ctx.hmr.registerConfig(filename, () => { throw 42 }) + writeFileSync(filename, 'invalid') + + const observed = await failure.promise + expect(observed.filename).toBe(filename) + expect(observed.error).toBeInstanceOf(Error) + expect(observed.error.message).toBe('42') + + writeFileSync(filename, 'invalid again') + await eventually(() => failureCount === 2, 'HMR stopped broadcasting after an observer rejected') + } finally { + await ctx.fiber.dispose() + } + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34eb7b8f87..dff92c6511 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5484,12 +5484,18 @@ importers: specifier: ^4.2.0 version: 4.2.0 devDependencies: + '@cordisjs/plugin-hmr': + specifier: workspace:^ + version: link:../../../vendor/hmr '@cordisjs/plugin-include': specifier: workspace:^ version: link:../../../vendor/include '@cordisjs/plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@cordisjs/plugin-timer': + specifier: workspace:^ + version: link:../../../vendor/timer '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index dc5c392549..97685a9086 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -303,7 +303,8 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = { { name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:340' }, { name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:342' }, { name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' }, - { name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' }, + { name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:22' }, + { name: 'hmr/config-update-failed', summary: 'A watched config-file refresh failed.', source: 'vendor/hmr/src/index.ts:29' }, { name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' }, { name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:24' }, { name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:25' }, diff --git a/vendor/README.md b/vendor/README.md index 2d3e1b6b05..1ad2b94e41 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -35,11 +35,12 @@ Keep this log exhaustive — every divergence from upstream must be listed. 3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references. 4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. 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. +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. `Fiber.update()` returns its `internal/update` waterfall result, allowing Loader callers to await a restart while preserving synchronous config validation. 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). `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/config/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. -10. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. +8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates run sequentially, undo earlier changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. +9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/ui/app-boot/tests/hmr-config.spec.ts`. +10. **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. +11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` 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/config/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`. ## Sync procedure diff --git a/vendor/cordis/src/events.ts b/vendor/cordis/src/events.ts index 7831fa75d1..2e862c97d4 100644 --- a/vendor/cordis/src/events.ts +++ b/vendor/cordis/src/events.ts @@ -334,7 +334,7 @@ export interface Events { /** Interception hook for a service binding (no core producer). */ 'internal/service'(this: Context, name: string, value: any): void /** Waterfall: a fiber config update is being applied; skip `next()` to veto. */ - 'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void): void + 'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void | Promise): void | Promise /** Waterfall: a service is being read through the context proxy. */ 'internal/get'(ctx: Context, name: string, error: Error, next: () => any): any /** Waterfall: a service is being written through the context proxy. */ diff --git a/vendor/cordis/src/fiber.ts b/vendor/cordis/src/fiber.ts index 61de8bed04..5511b39036 100644 --- a/vendor/cordis/src/fiber.ts +++ b/vendor/cordis/src/fiber.ts @@ -728,13 +728,13 @@ export class Fiber { * * @param config — the new raw config; validated before anything restarts. * @param noSave — hint for persistence hooks not to write the change back. - * @returns nothing; the restart runs behind the `internal/update` waterfall. - * @throws {ValidationError} when the new config fails validation. + * @returns the update waterfall result; the default restart returns a promise. + * @throws when validation, an update listener, or the restarted plugin fails. */ update(config: any, noSave = false) { this.assertActive() config = resolveConfig(this.runtime!, config) - this.context.waterfall(this, 'internal/update', config, noSave, () => { + return this.context.waterfall(this, 'internal/update', config, noSave, () => { this.config = config this._error = undefined return this.restart() diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index 9727580efd..65ce923dc3 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -1,9 +1,10 @@ -import { Context, Inject, Service, type Plugin } from 'cordis' +import { Context, Service, type Plugin } from 'cordis' import type { Dict } from 'cosmokit' import { ModuleLoader, type ModuleJob, type ResolveResult } from '@cordisjs/plugin-loader' import type { Include } from '@cordisjs/plugin-include' import { FSWatcher, watch, type ChokidarOptions } from 'chokidar' -import { relative, resolve } from 'node:path' +import { dirname, relative, resolve } from 'node:path' +import { stat } from 'node:fs/promises' import { handleError } from './error.ts' import type {} from '@cordisjs/plugin-timer' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -19,6 +20,13 @@ declare module 'cordis' { interface Events { 'hmr/change'(url: string): void 'hmr/reload'(reloads: Map): void + /** + * A watched config-file refresh failed. + * @param filename - Absolute path observed by HMR. + * @param error - Normalized refresh failure. + * @mode parallel + */ + 'hmr/config-update-failed'(filename: string, error: Error): Promise | void } } @@ -44,13 +52,42 @@ interface Reload { runtime?: Plugin.Runtime } -@Inject('loader') -@Inject('timer') +interface ConfigRefresh { + dirty: boolean + running?: Promise +} + +interface ConfigRegistration { + watcher: FSWatcher +} + +async function findWatchRoot(filename: string): Promise<{ root: string; depth: number }> { + let root = dirname(filename) + let depth = 0 + while (true) { + try { + if (!(await stat(root)).isDirectory()) throw new Error(`config watch parent is not a directory: ${root}`) + return { root, depth } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + const parent = dirname(root) + if (parent === root) throw error + root = parent + depth += 1 + } + } +} + class Hmr extends Service { + static inject = ['loader', 'timer'] + public baseDir: string private internal: ModuleLoader private watcher!: FSWatcher + private readonly configs = new Map() + private readonly configRefreshes = new WeakMap() + private readonly refreshTasks = new Set>() /** * Changes from externals will always trigger a full reload. @@ -82,6 +119,65 @@ class Hmr extends Service { this.baseDir = fileURLToPath(new URL(config.base || '.', ctx.baseUrl)) } + /** + * Watch one exact config path outside the configured module roots. + * @param filename - Config path, resolved against the HMR base directory. + * @param refresh - Refresh callback run serially on add, change, or unlink. + * @returns an asynchronous disposer once the exact watch is ready. + * @throws when HMR is inactive, the path is already registered, or watcher startup fails. + */ + async registerConfig(filename: string, refresh: () => Promise | void): Promise<() => Promise> { + if (!this.watcher) throw new Error('HMR is not active') + filename = resolve(this.baseDir, filename) + if (this.configs.has(filename)) throw new Error(`config path already registered: ${filename}`) + + const { root, depth } = await findWatchRoot(filename) + const watcher = watch(root, { + ...this.config, + cwd: undefined, + depth, + ignored: undefined, + ignoreInitial: false, + }) + const registration = { watcher } + this.configs.set(filename, registration) + const onChange = (path: string) => { + if (resolve(path) !== filename) return + this.refreshConfig(registration, filename, refresh) + } + watcher.on('add', onChange) + watcher.on('change', onChange) + watcher.on('unlink', onChange) + + const ready = Promise.withResolvers() + let readyState: 'pending' | 'resolved' | 'rejected' = 'pending' + watcher.once('ready', () => { + readyState = 'resolved' + ready.resolve() + }) + watcher.on('error', (error) => { + if (readyState === 'pending') { + readyState = 'rejected' + ready.reject(error) + } else { + this.ctx.logger.warn(error) + } + }) + + try { + await ready.promise + return this.ctx.effect(() => async () => { + if (this.configs.get(filename) === registration) this.configs.delete(filename) + await watcher.close() + await this.configRefreshes.get(registration)?.running + }, 'hmr.registerConfig()') + } catch (error) { + this.configs.delete(filename) + await watcher.close() + throw error + } + } + /** * Resolve a module specifier to a URL, compatible with Node 22-24. */ @@ -93,7 +189,12 @@ class Hmr extends Service { } async* [Service.init]() { - yield () => this.watcher?.close() + yield async () => { + await this.watcher?.close() + await Promise.allSettled([...this.configs.values()].map(registration => registration.watcher.close())) + this.configs.clear() + await Promise.allSettled([...this.refreshTasks]) + } const { loader } = this.ctx const { root, ignored } = this.config @@ -122,9 +223,18 @@ class Hmr extends Service { const partialReload = this.ctx.debounce(() => this.partialReload(), this.config.debounce) - this.watcher.on('change', async (path) => { - this.ctx.logger.debug('change detected at %C', path) + const onChange = (kind: 'add' | 'change' | 'unlink', path: string) => { + this.ctx.logger.debug('%s detected at %C', kind, path) const filename = resolve(this.baseDir, path) + // Config reload: the file is a loader config file (e.g. cordis.yml). + for (const entry of loader.entries()) { + const include = entry.subtree as Include | undefined + if (include?.filename !== filename) continue + this.refreshConfig(include, filename, () => include.refresh()) + return + } + + if (kind !== 'change') return const url = pathToFileURL(filename).href // Full reload: the changed file is part of the framework @@ -138,16 +248,40 @@ class Hmr extends Service { return partialReload() } - // Config reload: the file is a loader config file (e.g. cordis.yml) - for (const entry of this.ctx.loader.entries()) { - const include = entry.subtree as Include | undefined - if (include?.filename !== filename) continue - await include.refresh() - return - } - this.ctx.emit('hmr/change', url) + } + this.watcher.on('add', path => onChange('add', path)) + this.watcher.on('change', path => onChange('change', path)) + this.watcher.on('unlink', path => onChange('unlink', path)) + } + + private refreshConfig(key: object, filename: string, refresh: () => Promise | void) { + const state = this.configRefreshes.get(key) ?? { dirty: false } + this.configRefreshes.set(key, state) + state.dirty = true + if (state.running) return + const task = (async () => { + do { + state.dirty = false + try { + await refresh() + } catch (reason) { + const error = reason instanceof Error ? reason : new Error(String(reason), { cause: reason }) + this.ctx.logger.warn('config reload at %C failed', filename) + this.ctx.logger.warn(error) + try { + await this.ctx.parallel('hmr/config-update-failed', filename, error) + } catch (rejection) { + this.ctx.logger.warn(rejection) + } + } + } while (state.dirty) + })().finally(() => { + state.running = undefined + this.refreshTasks.delete(task) }) + state.running = task + this.refreshTasks.add(task) } // hide stack trace from HMR diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 29c894401c..43860dfd56 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -35,7 +35,8 @@ const supported = new Set(Object.keys(writable)) * Apply patch lists to an entry list — THE patch semantics of this include, * shared by mounting (`applyPatches`) and offline config tooling * (`dsh --dump-config`) so a dump can never drift from what boots. The input - * is never mutated: patching shared entry objects would bake earlier patch + * is never mutated and the result is always detached from it (even with no + * patches): patching or mounting shared entry objects would bake earlier * values into the cached parse, so repeated application (config hot-reloads) * could never revert a removed or changed patch. Inserted entries are indexed * as they are added, so a later patch in the same list can target a row an @@ -50,8 +51,8 @@ export function applyEntryPatches( patches: PatchOptions[] | undefined, warn: (message: string, ...args: any[]) => void, ): EntryOptions[] { - if (!patches?.length) return [...data] data = structuredClone(data) + if (!patches?.length) return data const entryMap = new Map() const buildMap = (entries: EntryOptions[]) => { @@ -117,6 +118,20 @@ export function applyEntryPatches( return data } +type ConfigUpdateStage = 'read' | 'parse' | 'validate' + +interface ReadCandidate { + content: string + data: EntryOptions[] +} + +class ConfigFileError extends Error { + constructor(public readonly stage: ConfigUpdateStage, path: string, cause: unknown) { + super(`failed to ${stage} config file ${path}`, { cause }) + this.name = 'ConfigFileError' + } +} + /** Runtime patch applied to entries loaded from an included config file. */ export interface PatchOptions { id?: string @@ -169,17 +184,11 @@ export class Include extends EntryTree { this.readonly = !this.type this.ctx.baseUrl = new URL('.', pathToFileURL(this.filename)).href - ctx.on('internal/update', (config, _, next) => { + ctx.on('internal/update', async (config, _, next) => { if (config.path !== this.config.path) return next() - // Veto the fiber restart (children update in place), but persist the new - // config ourselves — `Fiber.update` only assigns `this.config` behind - // `next()`, and a stale `this.config.patches` would make the next - // `refresh()` re-apply the old overlay. + const data = this.applyPatches(this.data!, config.patches) + await this.root.update(data) this.config = config - this.root.update(this.applyPatches(this.data!, config.patches)).catch((error) => { - this.ctx.logger.warn('config update at %C failed', this.filename) - this.ctx.logger.warn(error) - }) }) } @@ -192,30 +201,31 @@ export class Include extends EntryTree { } } - private async read(forced = false) { - const content = await readFile(this.filename, 'utf8') - if (!forced && this.content === content) return false + private async read(forced = false): Promise { + let content: string + try { + content = await readFile(this.filename, 'utf8') + } catch (error) { + throw new ConfigFileError('read', this.filename, error) + } + if (!forced && this.content === content) return let data: any - if (this.type === 'application/yaml') { - data = yaml.load(content, { schema }) - } else if (this.type === 'application/json') { - data = JSON.parse(content) - } else { - const module = await import(/* @vite-ignore */ this.filename) - data = module.default || module + try { + if (this.type === 'application/yaml') { + data = yaml.load(content, { schema }) + } else if (this.type === 'application/json') { + data = JSON.parse(content) + } else { + const module = await import(/* @vite-ignore */ this.filename) + data = module.default || module + } + } catch (error) { + throw new ConfigFileError('parse', this.filename, error) } - // An empty or truncated file (common mid-edit: editors and `sed -i` write - // through temp states) parses to `undefined`, not an error; reject every - // non-array shape here so callers see one "invalid file" signal. Content - // and data commit only on success, so an edit that is later reverted to - // the exact last good content correctly reads as "unchanged". if (!Array.isArray(data)) { - throw new TypeError(`config file must be a top-level array of entries: ${this.filename}`) + throw new ConfigFileError('validate', this.filename, new TypeError('config file must be a top-level array')) } - this.content = content - this.data = data - await this.checkAccess() - return true + return { content, data } } private applyPatches(data: EntryOptions[], patches = this.config.patches): EntryOptions[] { @@ -225,42 +235,44 @@ export class Include extends EntryTree { } async* [Service.init]() { + let candidate: ReadCandidate try { - await this.read() + candidate = (await this.read(true))! } catch (error) { - // Only a missing file falls back to `initial` (or the not-found error): - // an existing-but-invalid file must fail loud with its real parse error, - // never be mislabelled as absent or silently overwritten. - if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') throw error + if (!(error instanceof ConfigFileError) || error.stage !== 'read' || (error.cause as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error if (this.config.initial) { - this.writeFile(this.config.initial as any) - await this.read() + await this._writeFile(this.config.initial as any) + candidate = (await this.read(true))! } else { throw new Error(`config file not found: ${this.filename}`) } } yield () => this.stop() - await this.root.update(this.applyPatches(this.data!)) + await this.apply(candidate) } - stop() { - this.root.stop() + async stop() { + await this.root.stop() } /** - * Re-read the file and refresh child entries when content changed. An - * unreadable or unparsable file logs a warning and keeps the last good - * tree: a hot-reload of a live app must never take the process down. + * Re-read the file and transactionally refresh child entries when content changed. + * @returns a promise resolving after the new tree commits, or immediately when unchanged. + * @throws when reading, parsing, validation, application, or rollback fails; the last good tree remains active when rollback succeeds. */ async refresh() { - try { - if (!await this.read()) return - await this.root.update(this.applyPatches(this.data!)) - } catch (error) { - this.ctx.logger.warn('config reload at %C failed; keeping the running tree', this.filename) - this.ctx.logger.warn(error) - } + const candidate = await this.read() + if (!candidate) return + await this.apply(candidate) + } + + private async apply(candidate: ReadCandidate) { + const data = this.applyPatches(candidate.data) + await this.root.update(data) + this.content = candidate.content + this.data = candidate.data + await this.checkAccess() } private async _writeFile(config: EntryOptions[]) { diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index c2959fe61e..d479fa6c0f 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -21,6 +21,11 @@ export interface EntryOptions { inject?: Inject | null } +function updateError(stage: 'import' | 'dispose' | 'apply' | 'rollback', options: EntryOptions, cause: unknown) { + const detail = cause instanceof Error ? cause.message : String(cause) + return new Error(`failed to ${stage} loader entry ${options.id} (${options.name}): ${detail}`, { cause }) +} + function takeEntries(object: {}, keys: string[]) { const result: [string, any][] = [] for (const key of keys) { @@ -38,6 +43,11 @@ function sortKeys(object: T, prepend = ['id', 'name'], append = [' return Object.assign(object, Object.fromEntries([...part1, ...rest, ...part2])) } +function replaceKeys(target: T, source: T): T { + for (const key of Object.keys(target)) Reflect.deleteProperty(target, key) + return Object.assign(target, source) +} + /** One configured plugin node inside an `EntryTree`. */ export class Entry { static readonly key = Symbol.for('cordis.entry') @@ -51,6 +61,7 @@ export class Entry { public subtree?: EntryTree _initTask?: Promise + _disposing = 0 constructor(public loader: Loader) { this.ctx = loader.ctx.extend({ [Entry.key]: this }) @@ -71,13 +82,18 @@ export class Entry { /** True when this entry or any owning parent entry is disabled. */ get disabled() { + return this._disabled(this.options) + } + + private _disabled(options: EntryOptions) { // group is always enabled - if (this.options.group) return false - let entry: Entry | undefined = this - do { + if (options.group) return false + if (options.disabled) return true + let entry = this.parent.ctx.fiber.entry + while (entry) { if (entry.options.disabled) return true entry = entry.parent.ctx.fiber.entry - } while (entry) + } return false } @@ -90,12 +106,12 @@ export class Entry { return interpolate(this.ctx, this.options.config) } - private _patchContext(diff: string[]) { - this.context.waterfall('loader/patch-context', this, () => { + private async _patchContext(diff: string[]) { + await this.context.waterfall('loader/patch-context', this, async () => { Object.setPrototypeOf(this.ctx, this.parent.ctx) if (this.fiber?.uid && (diff.includes('config') || this.options.group)) { - this.fiber.update(this._resolveConfig(this.fiber.runtime!.callback), true) + await this.fiber.update(this._resolveConfig(this.fiber.runtime!.callback), true) } }) } @@ -106,41 +122,122 @@ export class Entry { await this.init() } + async _dispose(fiber = this.fiber) { + if (!fiber) return + if (this.fiber === fiber) this.fiber = undefined + this._disposing += 1 + try { + await fiber.dispose() + } finally { + this._disposing -= 1 + } + } + /** Merge new options, restart as needed, and persist through the parent tree. */ async update(options: Partial, create = false, force = false) { - const legacy = { ...this.options } - - // step 1: update options - if (create) { - this.options = options as EntryOptions - } else { + const previousOptions = this.options + const legacy = { ...previousOptions } + const candidate = create ? options as EntryOptions : { ...previousOptions } + if (!create) { for (const [key, value] of Object.entries(options)) { if (isNullable(value)) { - delete this.options[key] + delete candidate[key as keyof EntryOptions] } else { - this.options[key] = value + candidate[key as keyof EntryOptions] = value as never } } } - sortKeys(this.options) + sortKeys(candidate) - // step 2: execute - if (this.disabled) { - this.fiber?.dispose() + const diff = Object + .keys({ ...candidate, ...legacy }) + .filter(key => !deepEqual(candidate[key as keyof EntryOptions], legacy[key as keyof EntryOptions])) + if (!diff.length && !force) return + + const commit = () => { + if (create) return + this.options = replaceKeys(previousOptions, candidate) + } + + const previous = this.fiber + if (!previous?.uid) { + this.fiber = undefined + this.options = candidate + try { + if (!this._disabled(candidate)) await this.init() + } catch (error) { + this.options = previousOptions + throw error + } + commit() return } - // step 3: check if options are changed - if (this.fiber?.uid) { - const diff = Object - .keys({ ...this.options, ...legacy }) - .filter(key => !deepEqual(this.options[key], legacy[key])) - if (!diff.length && !force) return + if (this._disabled(candidate)) { + this.options = candidate + try { + await this._dispose(previous) + } catch (error) { + this.options = previousOptions + throw updateError('dispose', candidate, error) + } + commit() this.context.emit('loader/partial-dispose', this, legacy, true) - this._patchContext(diff) - } else { - await this.init() + return } + + const replace = diff.some(key => key === 'name' || key === 'inject' || key === 'group') + if (!replace) { + this.options = candidate + try { + await this._patchContext(diff) + } catch (error) { + this.options = previousOptions + try { + await this._patchContext(diff) + } catch (rollbackError) { + throw updateError('rollback', legacy, new AggregateError([error, rollbackError])) + } + this.context.emit('loader/partial-dispose', this, candidate, true) + throw updateError('apply', candidate, error) + } + commit() + this.context.emit('loader/partial-dispose', this, legacy, true) + return + } + + let plugin: any + try { + plugin = diff.includes('name') + ? this.loader.unwrapExports(await this.parent.tree.import(candidate.name, this.getOuterStack)) + : previous.runtime!.callback + } catch (error) { + throw updateError('import', candidate, error) + } + + const previousPlugin = previous.runtime!.callback + this.options = candidate + try { + await this._dispose(previous) + } catch (error) { + this.options = previousOptions + throw updateError('dispose', candidate, error) + } + + try { + await this._start(plugin) + } catch (error) { + this.options = previousOptions + try { + await this._start(previousPlugin) + } catch (rollbackError) { + throw updateError('rollback', legacy, new AggregateError([error, rollbackError])) + } + this.context.emit('loader/partial-dispose', this, candidate, true) + throw updateError('apply', candidate, error) + } + commit() + this.context.emit('loader/partial-dispose', this, legacy, true) } getOuterStack = () => { @@ -159,26 +256,39 @@ export class Entry { await (this._initTask ??= this._init()) } finally { this._initTask = undefined + if (!this.loader.getTasks().length) this.ctx.reflect.notify(['loader']) } - this.fiber?.await().finally(() => { - if (this.loader.getTasks().length) return - this.ctx.reflect.notify(['loader']) - }) + await this.fiber?.await() } private async _init() { - let exports: any + let plugin: any try { - exports = await this.parent.tree.import(this.options.name, this.getOuterStack) + plugin = this.loader.unwrapExports(await this.parent.tree.import(this.options.name, this.getOuterStack)) } catch (error) { - this.ctx.logger.error(error) - return - } finally { - this._initTask = undefined + throw updateError('import', this.options, error) } - const plugin = this.loader.unwrapExports(exports) - this._patchContext([]) + try { + await this._start(plugin) + } catch (error) { + throw updateError('apply', this.options, error) + } + } + + private async _start(plugin: any) { + let fiber: Fiber | undefined + try { + fiber = await this._create(plugin) + await fiber.await() + } catch (error) { + await this._dispose(fiber) + throw error + } + } + + private async _create(plugin: any): Promise { + await this._patchContext([]) this.loader.showLog(this, 'apply') - this.fiber = this.ctx.registry.plugin(plugin, this._resolveConfig(plugin), this.getOuterStack) + return this.fiber = this.ctx.registry.plugin(plugin, this._resolveConfig(plugin), this.getOuterStack) } } diff --git a/vendor/loader/src/config/group.ts b/vendor/loader/src/config/group.ts index a73e4dea0f..cdd613caf6 100644 --- a/vendor/loader/src/config/group.ts +++ b/vendor/loader/src/config/group.ts @@ -19,12 +19,23 @@ export class EntryGroup { async create(options: Omit) { const id = this.tree.ensureId(options) - const entry: Entry = this.tree.store[id] ??= new Entry(this.ctx.loader) + const existing = this.tree.store[id] + const entry: Entry = existing ?? (this.tree.store[id] = new Entry(this.ctx.loader)) + const previousParent = entry.parent // Entry may be moved from another group, // so we need to update the parent reference. entry.parent = this // Use `create: true` to replace existing entry.options. - await entry.update(options, true, true) + try { + await entry.update(options, true, true) + } catch (error) { + if (existing) { + entry.parent = previousParent + } else { + delete this.tree.store[id] + } + throw error + } return entry.id } @@ -34,10 +45,10 @@ export class EntryGroup { if (index >= 0) config.splice(index, 1) } - remove(id: string, isDispose = false) { + async remove(id: string, isDispose = false) { const entry = this.tree.store[id] if (!entry) return - entry.fiber?.dispose() + await entry._dispose() if (!isDispose) { this.unlink(entry.options) } @@ -47,26 +58,47 @@ export class EntryGroup { async update(config: EntryOptions[]) { const oldConfig = this.data as EntryOptions[] - this.data = config + const seen = new Set() + for (const options of config) { + const id = this.tree.ensureId(options) + if (seen.has(id)) throw new TypeError(`duplicate loader entry id: ${id}`) + seen.add(id) + } const oldMap = Object.fromEntries(oldConfig.map(options => [options.id, options])) - const newMap = Object.fromEntries(config.map(options => [options.id ?? Symbol('anonymous'), options])) + const newMap = Object.fromEntries(config.map(options => [options.id, options])) - // update inner plugins - const ids = Reflect.ownKeys({ ...oldMap, ...newMap }) as string[] - await Promise.all(ids.map(async (id) => { - if (newMap[id]) { - await this.create(newMap[id]).catch((error) => { - this.ctx.logger.error(error) - }) - } else { - this.remove(id) + try { + for (const options of config) await this.create(options) + for (const id of Object.keys(oldMap)) { + if (!newMap[id]) await this.remove(id, true) } - })) + this.data = config + } catch (error) { + const rollbackErrors: unknown[] = [] + for (const id of Object.keys(newMap).reverse()) { + if (oldMap[id]) continue + try { + await this.remove(id, true) + } catch (rollbackError) { + rollbackErrors.push(rollbackError) + } + } + for (const options of oldConfig) { + try { + await this.create(options) + } catch (rollbackError) { + rollbackErrors.push(rollbackError) + } + } + this.data = oldConfig + if (rollbackErrors.length) throw new AggregateError([error, ...rollbackErrors], 'loader entry rollback failed') + throw error + } } - stop() { + async stop() { for (const options of this.data) { - this.remove(options.id, true) + await this.remove(options.id, true) } } } @@ -78,9 +110,7 @@ export class Group extends EntryGroup { constructor(public ctx: Context, public config: EntryOptions[]) { super(ctx, ctx.fiber.entry!.parent.tree) - ctx.on('internal/update', (config) => { - this.update(config) - }) + ctx.on('internal/update', config => this.update(config)) } async* [Service.init]() { diff --git a/vendor/loader/src/config/isolate.ts b/vendor/loader/src/config/isolate.ts index 2361b41aaa..9142f3fda5 100644 --- a/vendor/loader/src/config/isolate.ts +++ b/vendor/loader/src/config/isolate.ts @@ -93,7 +93,7 @@ export default function isolate(ctx: Context) { entry.ctx[Context.isolate] = Object.create(entry.ctx[Context.isolate]) }) - ctx.on('loader/patch-context', (entry, next) => { + ctx.on('loader/patch-context', async (entry, next) => { // step 1: generate new isolate map const newMap: Dict = Object.create(entry.parent.ctx[Context.isolate]) for (const name of Object.keys(entry.options.isolate ?? {})) { @@ -126,7 +126,7 @@ export default function isolate(ctx: Context) { swap(entry.ctx[Context.intercept], entry.options.intercept) // step 4: reload fiber - next() + await next() // step 5: replace service impl for (const [symbol1, symbol2, flag1, flag2] of Object.values(diff)) { diff --git a/vendor/loader/src/config/tree.ts b/vendor/loader/src/config/tree.ts index 79db440601..8cb9fb984d 100644 --- a/vendor/loader/src/config/tree.ts +++ b/vendor/loader/src/config/tree.ts @@ -39,12 +39,27 @@ export abstract class EntryTree { .filter(isNonNullable) } - /** Wait until this tree has no pending import or lifecycle tasks. */ + /** + * Wait until this tree has no active import or lifecycle tasks. + * @throws a settled fiber failure, or an aggregate when several fibers failed. + */ async await() { while (true) { const tasks = this.getTasks() - if (!tasks.length) return - await Promise.allSettled(tasks) + if (tasks.length) { + await Promise.allSettled(tasks) + continue + } + const outcomes = await Promise.allSettled( + [...this.entries()].map(entry => entry.fiber?.await()), + ) + const failures = outcomes + .filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') + .map(outcome => outcome.reason) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'loader fibers failed') + this.ctx.reflect.notify(['loader']) + if (!this.getTasks().length) return } } @@ -81,15 +96,17 @@ export abstract class EntryTree { /** Create an entry in the root group or a nested group. */ async create(options: Omit, parent: string | null = null, position = Infinity) { const group = this.resolveGroup(parent) - group.data.splice(position, 0, options as EntryOptions) + const id = await group.create(options) + const entry = this.resolve(id) + group.data.splice(position, 0, entry.options) group.tree.write() - return group.create(options) + return id } /** Stop and remove an entry from its parent group. */ - remove(id: string) { + async remove(id: string) { const entry = this.resolve(id) - entry.parent.remove(id) + await entry.parent.remove(id) entry.parent.tree.write() } @@ -97,15 +114,31 @@ export abstract class EntryTree { async update(id: string, options: Omit, parent?: string | null, position?: number) { const entry = this.resolve(id) const source = entry.parent + const sourceIndex = source.data.indexOf(entry.options) + let target = source if (parent !== undefined) { - const target = this.resolveGroup(parent) + target = this.resolveGroup(parent) source.unlink(entry.options) target.data.splice(position ?? Infinity, 0, entry.options) - target.tree.write() entry.parent = target } + try { + await entry.update(options, false, true) + } catch (error) { + if (parent !== undefined) { + target.unlink(entry.options) + source.data.splice(sourceIndex < 0 ? source.data.length : sourceIndex, 0, entry.options) + entry.parent = source + try { + await entry.update({}, false, true) + } catch (rollbackError) { + throw new AggregateError([error, rollbackError], `failed to roll back loader entry move ${id}`) + } + } + throw error + } source.tree.write() - return entry.update(options, false, true) + if (target !== source) target.tree.write() } /** Import a plugin module from a specifier or `cordis:` builtin. */ diff --git a/vendor/loader/src/index.ts b/vendor/loader/src/index.ts index 1e963ea073..798354c7b0 100644 --- a/vendor/loader/src/index.ts +++ b/vendor/loader/src/index.ts @@ -24,7 +24,7 @@ declare module 'cordis' { 'loader/config-update'(): void 'loader/entry-init'(entry: Entry): void 'loader/partial-dispose'(entry: Entry, legacy: Partial, active: boolean): void - 'loader/patch-context'(entry: Entry, next: () => void): void + 'loader/patch-context'(entry: Entry, next: () => void | Promise): void | Promise } interface Context { @@ -87,12 +87,12 @@ export class Loader extends EntryTree { ctx.reflect.provide('loader', this, this[Service.check]) - ctx.on('internal/update', function (config, noSave, next) { + ctx.on('internal/update', async function (config, noSave, next) { if (!this.entry || noSave || this.parent.fiber?.entry === this.entry) return next() + await next() const unparse = this.runtime?.Config?.['simplify'] this.entry.options.config = unparse ? unparse(config) : config this.entry.parent.tree.write() - return next() }, { global: true, prepend: true }) ctx.on('internal/update', function (config, _, next) { @@ -129,9 +129,12 @@ export class Loader extends EntryTree { // case 5: the entry's tree is being disposed if (!fiber.entry.parent.tree.ctx.fiber.uid) return + // case 6: Loader is replacing or removing this exact fiber + if (fiber.entry._disposing) return + this.showLog(fiber.entry, 'unload') - // case 6: fiber is disposed by loader behavior + // case 7: fiber is disposed by loader behavior // such as inject checker, config file update, ancestor group disable if (fiber.entry.disabled) return From 560f3abd21da8fa8e3a2517cc04c2ed9fa571eff Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 04:46:04 +0800 Subject: [PATCH 10/23] fix(vendor): link Cordis workspaces in built artifacts --- ...26-06-11-vendor-cordis-as-source.i18n.yaml | 6 +- .../2026-06-11-vendor-cordis-as-source.md | 3 +- .../2026-06-11-vendor-cordis-as-source.zh.md | 3 +- pnpm-lock.yaml | 678 ++++++++---------- pnpm-workspace.yaml | 4 + vendor/README.md | 2 +- vendor/schemastery/package.json | 9 + 7 files changed, 312 insertions(+), 393 deletions(-) diff --git a/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml b/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml index 0e0c6693a2..93cf9ec401 100644 --- a/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.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-06-11-vendor-cordis-as-source.md: ae6f5438c5817c61a549d9edb2041d538fbcebe6 -2026-06-11-vendor-cordis-as-source.zh.md: 8d6f0e39d53e1c85eaaa50c4c4bf1d9ef648d953 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.md +2026-06-11-vendor-cordis-as-source.md: ccc1289c8a0feadc08d80a3b6e8dc674c1b87bc4 +2026-06-11-vendor-cordis-as-source.zh.md: 9abea504d677ab71c62d24e2e4d9dfde4315802c diff --git a/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.md b/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.md index ae6f5438c5..ccc1289c8a 100644 --- a/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.md +++ b/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.md @@ -10,7 +10,7 @@ DeepSeek Harness SDK is built on the Cordis framework. Cordis core was at 4.0.0- ## Decision -Copy the needed Cordis packages (core, loader, include, group, timer, hmr, logger-console) and the cordiverse foundation libraries (cosmokit, schemastery) into `vendor/` as source, flattened, keeping their original npm names so workspace resolution is transparent. Truly third-party dependencies (js-yaml, chokidar, @standard-schema/spec, …) stay on npm. +Copy the needed Cordis packages (core, loader, include, group, timer, hmr, logger-console) and the cordiverse foundation libraries (cosmokit, schemastery) into `vendor/` as source, flattened, keeping their original npm names so workspace resolution is transparent. `pnpm-workspace.yaml` sets `linkWorkspacePackages: true`, so matching upstream semver ranges resolve these pinned workspaces in both source and built-artifact execution. Truly third-party dependencies (js-yaml, chokidar, @standard-schema/spec, …) stay on npm. `vendor/README.md` is the manifest: upstream repo + commit SHA per package and an exhaustive local-modification log. A pre-commit guard (`scripts/check-vendor-manifest.sh`) rejects vendored-source changes that don't update the manifest in the same commit. @@ -22,6 +22,7 @@ Copy the needed Cordis packages (core, loader, include, group, timer, hmr, logge ## Consequences - The harness fully owns its framework layer: auditable, patchable, pinned — an RC upstream can't break us, and we can fix framework bugs in-tree. +- Built packages execute the same vendored Cordis generation as source tests; removing workspace linking would silently substitute npm copies behind unchanged package names. - Upstream sync is manual (documented procedure in the manifest). The modification log keeps the diff surface known. - Vendored packages keep upstream code style; lint/strictness gates exclude them (their tsconfigs relax our newer compiler flags locally). - One local patch exists from day one: hmr's locale-YAML imports removed (the runtime YAML import hook isn't vendored). diff --git a/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md b/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md index 8d6f0e39d5..9abea504d6 100644 --- a/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md +++ b/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md @@ -10,7 +10,7 @@ DeepSeek Harness SDK 构建于 Cordis 框架之上。本仓库启动时,Cordis ## 决策 -将所需的 Cordis 包(core、loader、include、group、timer、hmr、logger-console)与 cordiverse 基础库(cosmokit、schemastery)以源码形式复制到 `vendor/`,扁平化放置,保留其原始 npm 包名以实现透明的 workspace 解析。真正的第三方依赖(js-yaml、chokidar、@standard-schema/spec 等)仍从 npm 获取。 +将所需的 Cordis 包(core、loader、include、group、timer、hmr、logger-console)与 cordiverse 基础库(cosmokit、schemastery)以源码形式复制到 `vendor/`,扁平化放置,保留其原始 npm 包名以实现透明的 workspace 解析。`pnpm-workspace.yaml` 设置 `linkWorkspacePackages: true`,所以只要上游 semver 范围匹配,无论以源码执行还是以构建产物执行,依赖都会解析到这些固定版本的 workspace。真正的第三方依赖(js-yaml、chokidar、@standard-schema/spec 等)仍从 npm 获取。 `vendor/README.md` 是 manifest(元数据清单):记录每个包(package)的上游仓库 + commit SHA,以及一份详尽的本地修改日志。pre-commit 守卫(`scripts/check-vendor-manifest.sh`)会拒绝未在同一次提交中更新 manifest 的 vendor 源码变更。 @@ -22,6 +22,7 @@ DeepSeek Harness SDK 构建于 Cordis 框架之上。本仓库启动时,Cordis ## 后果 - harness 完全持有其框架层:可审计、可打补丁、版本锁定。上游 RC 无法影响我们,框架 bug 可以在仓库内直接修复。 +- 构建后的包与源码测试执行的是同一版收录的 Cordis;移除 workspace 链接后,构建后的包会在包名不变的情况下静默改用 npm 副本。 - 上游同步是手动操作(流程记录在 manifest 中)。修改日志使 diff 范围始终可知。 - 收录的包保留上游代码风格;lint 与严格性门禁将其排除(它们的 tsconfig 在本地放宽了我们较新的编译器选项)。 - 从第一天起就有一个本地补丁:移除了 hmr 的 locale-YAML 导入(运行时 YAML 导入钩子未被收录)。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dff92c6511..cefd39d0ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -488,7 +488,7 @@ importers: version: 15.0.0 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../vendor/cordis js-yaml: specifier: ^4.2.0 version: 4.2.0 @@ -841,7 +841,7 @@ importers: version: 0.25.1(zod@4.4.3) schemastery: specifier: ^3.17.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -869,7 +869,7 @@ importers: version: link:../../ui/user-approval 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) + version: link:../../../vendor/cordis packages/bash/bash: devDependencies: @@ -884,13 +884,13 @@ importers: version: link:../../subprocess/subprocess 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) + version: link:../../../vendor/cordis packages/bash/bash-local: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-bash': specifier: workspace:^ @@ -909,7 +909,7 @@ importers: version: link:../../util/timeout 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) + version: link:../../../vendor/cordis packages/bash/bash-sandbox: devDependencies: @@ -936,7 +936,7 @@ importers: version: link:../../subprocess/subprocess-local 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) + version: link:../../../vendor/cordis node-addon-landlock-run: specifier: 0.0.0-test.0 version: 0.0.0-test.0 @@ -945,7 +945,7 @@ importers: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -1009,7 +1009,7 @@ importers: version: link:../../ui/user-approval 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) + version: link:../../../vendor/cordis packages/client/connection: dependencies: @@ -1030,7 +1030,7 @@ importers: version: link:../../core/tools schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ @@ -1040,13 +1040,13 @@ importers: 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) + version: link:../../../vendor/cordis packages/client/hmr: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -1062,7 +1062,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/client/locale: devDependencies: @@ -1083,7 +1083,7 @@ importers: 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) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1101,7 +1101,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/client/runtime: dependencies: @@ -1156,20 +1156,20 @@ importers: 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) + version: link:../../../vendor/cordis packages/client/schema-form: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery 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) + version: link:../../../vendor/cordis packages/client/test-runtime: dependencies: @@ -1206,7 +1206,7 @@ importers: version: 18.3.7(@types/react@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) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1252,7 +1252,7 @@ importers: 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) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1310,7 +1310,7 @@ importers: 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) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1352,7 +1352,7 @@ importers: 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) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1382,7 +1382,7 @@ importers: 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) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1424,7 +1424,7 @@ importers: version: 2.1.1 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) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1466,7 +1466,7 @@ importers: 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) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1511,7 +1511,7 @@ importers: 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) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1553,7 +1553,7 @@ importers: 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) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1614,7 +1614,7 @@ importers: version: 18.3.7(@types/react@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) + version: link:../../../vendor/cordis packages/client/ui-question: dependencies: @@ -1666,7 +1666,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/client/ui-settings: dependencies: @@ -1700,7 +1700,7 @@ importers: version: 18.3.7(@types/react@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) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1715,7 +1715,7 @@ importers: version: link:../../settings/settings schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ @@ -1749,7 +1749,7 @@ importers: 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) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1786,7 +1786,7 @@ importers: 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) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1810,7 +1810,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/client/ui-slash: dependencies: @@ -1841,7 +1841,7 @@ importers: 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) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1856,7 +1856,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/client/ui-subagent: devDependencies: @@ -1874,7 +1874,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/client/ui-theme: dependencies: @@ -1905,7 +1905,7 @@ importers: 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) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1936,7 +1936,7 @@ importers: 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) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1976,7 +1976,7 @@ importers: 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) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -2028,7 +2028,7 @@ importers: version: 18.3.7(@types/react@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@vendor+loader) + version: link:../../../vendor/cordis typescript: specifier: ^6.0.3 version: 6.0.3 @@ -2053,7 +2053,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/code-runtime/code-runtime: devDependencies: @@ -2062,13 +2062,13 @@ importers: 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) + version: link:../../../vendor/cordis packages/code-runtime/code-runtime-worker: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-code-runtime': specifier: workspace:^ @@ -2084,7 +2084,7 @@ importers: version: link:../../util/timeout 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) + version: link:../../../vendor/cordis packages/compact/command-compact: devDependencies: @@ -2114,7 +2114,7 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/compact/compact: devDependencies: @@ -2129,13 +2129,13 @@ importers: version: link:../../core/session 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) + version: link:../../../vendor/cordis packages/compact/compact-basic: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-include': specifier: workspace:^ @@ -2178,13 +2178,13 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/compact/compact-tool-result-prune: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-include': specifier: workspace:^ @@ -2203,13 +2203,13 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/context/session-reference: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -2234,13 +2234,13 @@ importers: version: link:../../session-query/session-query 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) + version: link:../../../vendor/cordis packages/context/time-context: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -2271,13 +2271,13 @@ importers: 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) + version: link:../../../vendor/cordis packages/context/tmux-context: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -2299,13 +2299,13 @@ importers: 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) + version: link:../../../vendor/cordis packages/context/workspace-context: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -2348,17 +2348,17 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/cordis/tool-cordis: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../../../vendor/loader '@cordisjs/plugin-timer': specifier: workspace:^ version: link:../../../vendor/timer @@ -2391,7 +2391,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/core/agent: devDependencies: @@ -2415,13 +2415,13 @@ importers: version: link:../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) + version: link:../../../vendor/cordis packages/core/agent-loop: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -2452,7 +2452,7 @@ importers: version: link:../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) + version: link:../../../vendor/cordis packages/core/scope: devDependencies: @@ -2461,7 +2461,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/core/session: devDependencies: @@ -2479,13 +2479,13 @@ importers: version: link:../scope 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) + version: link:../../../vendor/cordis packages/core/system-prompt: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -2498,13 +2498,13 @@ importers: version: link:../scope 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) + version: link:../../../vendor/cordis packages/core/tools: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -2532,7 +2532,7 @@ importers: version: link:../../ui/user-approval 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) + version: link:../../../vendor/cordis packages/credentials/credentials: devDependencies: @@ -2544,7 +2544,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/credentials/credentials-local: dependencies: @@ -2556,7 +2556,7 @@ importers: version: 17.4.2 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-atomic-write': specifier: workspace:^ @@ -2572,7 +2572,7 @@ importers: version: link:../../util/paths 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) + version: link:../../../vendor/cordis packages/examples/acp-demo: devDependencies: @@ -2620,16 +2620,16 @@ importers: version: link:../../context/workspace-context cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis schemastery: specifier: ^3.17.0 - version: 3.18.0 + version: link:../../../vendor/schemastery packages/examples/agent-spine-demo: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-timer': specifier: workspace:^ @@ -2729,7 +2729,7 @@ importers: 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@1.0.0-rc.5) + version: link:../../../vendor/cordis node-addon-landlock-run: specifier: 0.0.0-test.0 version: 0.0.0-test.0 @@ -2777,10 +2777,10 @@ importers: version: link:../../context/workspace-context cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis schemastery: specifier: ^3.17.0 - version: 3.18.0 + version: link:../../../vendor/schemastery packages/examples/jsonrpc-demo: dependencies: @@ -2793,7 +2793,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/fs/fs: devDependencies: @@ -2811,7 +2811,7 @@ importers: version: link:../../sandbox/sandbox 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) + version: link:../../../vendor/cordis packages/fs/fs-local: dependencies: @@ -2820,7 +2820,7 @@ importers: version: 3.1.1 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-fs': specifier: workspace:^ @@ -2833,7 +2833,7 @@ importers: version: link:../../llm/llm 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) + version: link:../../../vendor/cordis packages/fs/fs-policy: devDependencies: @@ -2848,7 +2848,7 @@ importers: version: link:../../llm/llm 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) + version: link:../../../vendor/cordis packages/fs/fs-sandbox: devDependencies: @@ -2869,7 +2869,7 @@ importers: version: link:../../sandbox/sandbox-policy 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) + version: link:../../../vendor/cordis packages/fs/tool-fs: dependencies: @@ -2878,7 +2878,7 @@ importers: version: 9.0.0 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -2927,13 +2927,13 @@ importers: version: link:../../ui/user-approval 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) + version: link:../../../vendor/cordis packages/fs/tool-fs-search: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -2970,13 +2970,13 @@ importers: version: link:../../core/tools 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) + version: link:../../../vendor/cordis packages/fs/tool-str-replace-editor: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3016,7 +3016,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/goal/command-goal: devDependencies: @@ -3043,13 +3043,13 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/goal/goal: dependencies: schemastery: specifier: ^3.17.2 - version: 3.18.0 + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 @@ -3080,7 +3080,7 @@ importers: version: link:../../session-projection/session-projection 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) + version: link:../../../vendor/cordis packages/goal/goal-session: devDependencies: @@ -3113,13 +3113,13 @@ importers: 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) + version: link:../../../vendor/cordis packages/goal/tool-goal: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -3147,13 +3147,13 @@ importers: 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@vendor+loader) + version: link:../../../vendor/cordis packages/guard/repeat-tool-guard: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3178,7 +3178,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/hooks/hook-protocol: devDependencies: @@ -3193,13 +3193,13 @@ importers: version: link:../../core/session 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) + version: link:../../../vendor/cordis packages/hooks/hooks-claude: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3245,13 +3245,13 @@ importers: 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) + version: link:../../../vendor/cordis packages/hooks/hooks-codex: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3294,7 +3294,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/host/apiproxy: dependencies: @@ -3360,7 +3360,7 @@ importers: version: link:../../workspace/workspace schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 @@ -3376,7 +3376,7 @@ importers: version: link:../../storage/storage-domain 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) + version: link:../../../vendor/cordis packages/host/directory-picker: devDependencies: @@ -3385,7 +3385,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/host/directory-picker-auto: devDependencies: @@ -3412,7 +3412,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/host/directory-picker-browse: dependencies: @@ -3424,7 +3424,7 @@ importers: version: 2.1.1 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ @@ -3452,7 +3452,7 @@ importers: 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) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -3483,7 +3483,7 @@ importers: 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) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -3492,20 +3492,20 @@ importers: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery 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) + version: link:../../../vendor/cordis packages/llm/llm: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -3518,7 +3518,7 @@ importers: version: link:../../util/timeout 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) + version: link:../../../vendor/cordis packages/llm/llm-deepseek: dependencies: @@ -3527,7 +3527,7 @@ importers: version: 3.1.0 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-credentials': specifier: workspace:^ @@ -3546,7 +3546,7 @@ importers: version: link:../../util/timeout 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) + version: link:../../../vendor/cordis packages/llm/llm-pi-ai: dependencies: @@ -3555,7 +3555,7 @@ importers: version: 0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-credentials': specifier: workspace:^ @@ -3577,13 +3577,13 @@ importers: version: link:../../util/timeout 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) + version: link:../../../vendor/cordis packages/llm/llm-retry: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-include': specifier: workspace:^ @@ -3632,13 +3632,13 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/llm/token-meter: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 @@ -3657,7 +3657,7 @@ importers: version: link:../../session-projection/session-projection 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) + version: link:../../../vendor/cordis packages/lsp/lsp: devDependencies: @@ -3672,13 +3672,13 @@ importers: version: link:../../llm/llm 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) + version: link:../../../vendor/cordis packages/lsp/lsp-local: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -3703,7 +3703,7 @@ importers: version: link:../../util/timeout 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) + version: link:../../../vendor/cordis typescript: specifier: ^6.0.3 version: 6.0.3 @@ -3715,7 +3715,7 @@ importers: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3749,7 +3749,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/mcp/mcp-client: dependencies: @@ -3758,7 +3758,7 @@ importers: version: 1.29.0(zod@4.4.3) schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 @@ -3783,7 +3783,7 @@ importers: version: 2026.7.10(zod@4.4.3) 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) + version: link:../../../vendor/cordis packages/plan/plan-mode: dependencies: @@ -3826,7 +3826,7 @@ importers: version: link:../../ui/user-interaction 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) + version: link:../../../vendor/cordis packages/pty/pty: devDependencies: @@ -3844,7 +3844,7 @@ importers: version: link:../../core/session 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) + version: link:../../../vendor/cordis packages/pty/pty-local: dependencies: @@ -3853,7 +3853,7 @@ importers: version: 1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6) schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3878,13 +3878,13 @@ importers: version: link:../../subprocess/subprocess 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) + version: link:../../../vendor/cordis packages/pty/tool-bash-persistent: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-include': specifier: workspace:^ @@ -3927,13 +3927,13 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/pty/tool-pty: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-include': specifier: workspace:^ @@ -3985,7 +3985,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/sandbox/sandbox: devDependencies: @@ -3997,7 +3997,7 @@ importers: version: link:../../llm/llm 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) + version: link:../../../vendor/cordis packages/sandbox/sandbox-local: dependencies: @@ -4006,7 +4006,7 @@ importers: version: 0.0.0-test.0 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -4019,13 +4019,13 @@ importers: version: link:../sandbox 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) + version: link:../../../vendor/cordis packages/sandbox/sandbox-policy: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4044,7 +4044,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/sdk/create-sdk: dependencies: @@ -4060,7 +4060,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/sdk/helper: dependencies: @@ -4109,7 +4109,7 @@ importers: version: link:../../web/tool-web 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) + version: link:../../../vendor/cordis packages/sdk/scripts: dependencies: @@ -4134,7 +4134,7 @@ importers: 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) + version: link:../../../vendor/cordis tsdown: specifier: ^0.22.2 version: 0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3) @@ -4158,7 +4158,7 @@ importers: version: link:../../core/session 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) + version: link:../../../vendor/cordis packages/sdk/sdk-protocol: devDependencies: @@ -4176,7 +4176,7 @@ importers: version: link:../../subagent/subagent 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) + version: link:../../../vendor/cordis packages/sdk/telemetry: dependencies: @@ -4195,7 +4195,7 @@ importers: version: link:../../util/paths 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) + version: link:../../../vendor/cordis packages/session-persistence/session-checkpoint-policy: devDependencies: @@ -4234,7 +4234,7 @@ importers: 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@vendor+loader) + version: link:../../../vendor/cordis packages/session-persistence/session-persistence: devDependencies: @@ -4252,7 +4252,7 @@ importers: version: link:../../core/session 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) + version: link:../../../vendor/cordis packages/session-persistence/session-persistence-jsonl: dependencies: @@ -4261,7 +4261,7 @@ importers: version: 3.1.1 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -4274,13 +4274,13 @@ importers: version: link:../session-persistence 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) + version: link:../../../vendor/cordis packages/session-persistence/session-persistence-sqlite: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -4293,7 +4293,7 @@ importers: version: link:../session-persistence 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) + version: link:../../../vendor/cordis packages/session-projection/session-projection: dependencies: @@ -4309,13 +4309,13 @@ importers: version: link:../../core/session 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) + version: link:../../../vendor/cordis packages/session-projection/session-projection-cache: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 @@ -4340,7 +4340,7 @@ importers: version: link:../../storage/storage-domain 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) + version: link:../../../vendor/cordis packages/session-query/session-query: devDependencies: @@ -4364,13 +4364,13 @@ importers: version: link:../../session-title/session-title 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) + version: link:../../../vendor/cordis packages/session-query/session-query-sqlite: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -4392,13 +4392,13 @@ importers: version: link:../session-query cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/session-query/tool-session-query: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4441,13 +4441,13 @@ importers: 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) + version: link:../../../vendor/cordis packages/session-title/session-title: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 @@ -4475,13 +4475,13 @@ importers: version: link:../../session-projection/session-projection 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) + version: link:../../../vendor/cordis packages/session-title/session-title-all-messages-llm: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -4500,13 +4500,13 @@ importers: version: link:../session-title-llm 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) + version: link:../../../vendor/cordis packages/session-title/session-title-first-message-llm: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-include': specifier: workspace:^ @@ -4534,13 +4534,13 @@ importers: version: link:../session-title-llm cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/session-title/session-title-llm: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -4559,7 +4559,7 @@ importers: version: link:../../util/timeout 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) + version: link:../../../vendor/cordis packages/settings/settings: devDependencies: @@ -4571,10 +4571,10 @@ importers: 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) + version: link:../../../vendor/cordis schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery packages/settings/settings-local: dependencies: @@ -4583,7 +4583,7 @@ importers: version: 4.0.3 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery yaml: specifier: ^2.9.0 version: 2.9.0 @@ -4602,20 +4602,20 @@ importers: version: link:../settings 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) + version: link:../../../vendor/cordis packages/skill/skill: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery 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) + version: link:../../../vendor/cordis packages/skill/skill-local: dependencies: @@ -4624,7 +4624,7 @@ importers: version: 5.0.0 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery yaml: specifier: ^2.4.2 version: 2.9.0 @@ -4643,13 +4643,13 @@ importers: version: link:../skill 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) + version: link:../../../vendor/cordis packages/skill/tool-skill: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4677,7 +4677,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/spill/spill: devDependencies: @@ -4695,13 +4695,13 @@ importers: 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) + version: link:../../../vendor/cordis packages/spill/spill-local: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -4720,13 +4720,13 @@ importers: version: link:../spill 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) + version: link:../../../vendor/cordis packages/spill/spill-policy: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4754,7 +4754,7 @@ importers: version: link:../../core/tools 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) + version: link:../../../vendor/cordis packages/storage/storage: devDependencies: @@ -4763,13 +4763,13 @@ importers: 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) + version: link:../../../vendor/cordis packages/storage/storage-domain: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 @@ -4782,13 +4782,13 @@ importers: version: link:../storage 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) + version: link:../../../vendor/cordis packages/storage/storage-json: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -4798,13 +4798,13 @@ importers: version: link:../storage 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) + version: link:../../../vendor/cordis packages/storage/storage-sqlite: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -4814,7 +4814,7 @@ importers: version: link:../storage 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) + version: link:../../../vendor/cordis packages/subagent/subagent: devDependencies: @@ -4841,7 +4841,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/subagent/subagent-acp: dependencies: @@ -4850,11 +4850,11 @@ importers: version: 0.25.1(zod@4.4.3) schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4881,17 +4881,17 @@ importers: version: link:../../subprocess/subprocess-local 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) + version: link:../../../vendor/cordis packages/subagent/subagent-dsh-sdk: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4921,17 +4921,17 @@ importers: version: link:../../subprocess/subprocess 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) + version: link:../../../vendor/cordis packages/subagent/subagent-fork: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4961,7 +4961,7 @@ importers: version: link:../subagent-spawn 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) + version: link:../../../vendor/cordis packages/subagent/subagent-inprocess: devDependencies: @@ -5006,17 +5006,17 @@ importers: version: link:../../ui/user-approval 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) + version: link:../../../vendor/cordis packages/subagent/subagent-spawn: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -5058,17 +5058,17 @@ importers: version: link:../tool-subagent 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) + version: link:../../../vendor/cordis packages/subagent/tool-subagent: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -5098,7 +5098,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/subprocess/subprocess: devDependencies: @@ -5107,7 +5107,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/subprocess/subprocess-local: devDependencies: @@ -5119,7 +5119,7 @@ importers: version: link:../subprocess 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) + version: link:../../../vendor/cordis packages/support/acp-snapshot: dependencies: @@ -5138,7 +5138,7 @@ importers: version: link:../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) + version: link:../../../vendor/cordis packages/support/agent-loop-testkit: devDependencies: @@ -5165,17 +5165,17 @@ importers: 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) + version: link:../../../vendor/cordis packages/support/invariants: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: 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) + version: link:../../../vendor/cordis packages/support/llm-mock-server: devDependencies: @@ -5184,7 +5184,7 @@ importers: version: link:../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) + version: link:../../../vendor/cordis packages/support/llm-replay: devDependencies: @@ -5199,7 +5199,7 @@ importers: version: link:../../core/session 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) + version: link:../../../vendor/cordis packages/support/loader-smoke: dependencies: @@ -5215,7 +5215,7 @@ importers: version: link:../invariants 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) + version: link:../../../vendor/cordis packages/tasks/tasks: devDependencies: @@ -5233,7 +5233,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/tasks/tasks-local: devDependencies: @@ -5257,13 +5257,13 @@ importers: version: link:../../util/timeout 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) + version: link:../../../vendor/cordis packages/tasks/tool-tasks: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -5294,7 +5294,7 @@ importers: version: link:../../core/tools 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) + version: link:../../../vendor/cordis packages/telemetry/session-telemetry: devDependencies: @@ -5309,7 +5309,7 @@ importers: version: link:../../core/session 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) + version: link:../../../vendor/cordis packages/telemetry/session-telemetry-otel: dependencies: @@ -5333,7 +5333,7 @@ importers: version: 0.220.0(@opentelemetry/api@1.9.1) schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -5358,7 +5358,7 @@ importers: version: link:../session-telemetry cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/timeout/timeout-policy: devDependencies: @@ -5376,7 +5376,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/todo/tool-todo: dependencies: @@ -5419,7 +5419,7 @@ importers: version: link:../../ui/user-interaction 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) + version: link:../../../vendor/cordis packages/typert/generator: dependencies: @@ -5438,7 +5438,7 @@ importers: version: link:../registry 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) + version: link:../../../vendor/cordis zod: specifier: ^4.4.3 version: 4.4.3 @@ -5447,7 +5447,7 @@ importers: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -5460,7 +5460,7 @@ importers: version: link:../registry cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis zod: specifier: ^4.4.3 version: 4.4.3 @@ -5476,7 +5476,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/ui/app-boot: dependencies: @@ -5510,7 +5510,7 @@ importers: version: 4.0.9 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/ui/commands: devDependencies: @@ -5531,13 +5531,13 @@ importers: version: link:../../core/session 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) + version: link:../../../vendor/cordis packages/ui/jsonrpc: dependencies: schemastery: specifier: ^3.17.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -5574,13 +5574,13 @@ importers: version: link:../../subagent/subagent cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/ui/permission: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 @@ -5614,7 +5614,7 @@ importers: version: link:../user-approval 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) + version: link:../../../vendor/cordis packages/ui/tool-ask-user: devDependencies: @@ -5638,7 +5638,7 @@ importers: version: link:../user-interaction 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) + version: link:../../../vendor/cordis packages/ui/tui: dependencies: @@ -5650,7 +5650,7 @@ importers: version: 6.0.0 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -5726,13 +5726,13 @@ importers: version: 5.5.0 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/ui/user-approval: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -5757,7 +5757,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/ui/user-interaction: devDependencies: @@ -5772,7 +5772,7 @@ importers: version: link:../../llm/llm 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) + version: link:../../../vendor/cordis packages/util/atomic-write: devDependencies: @@ -5781,7 +5781,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/util/brand: devDependencies: @@ -5790,7 +5790,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/util/native-command: devDependencies: @@ -5799,7 +5799,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/util/paths: devDependencies: @@ -5808,7 +5808,7 @@ importers: version: link:../../support/invariants 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) + version: link:../../../vendor/cordis packages/util/retention: devDependencies: @@ -5817,7 +5817,7 @@ importers: version: link:../../support/invariants 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) + version: link:../../../vendor/cordis packages/util/timeout: devDependencies: @@ -5826,7 +5826,7 @@ importers: 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) + version: link:../../../vendor/cordis packages/web/tool-web: dependencies: @@ -5835,7 +5835,7 @@ importers: version: 1.0.67 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery turndown: specifier: ^7.2.4 version: 7.2.4 @@ -5881,13 +5881,13 @@ importers: version: 5.0.6 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) + version: link:../../../vendor/cordis packages/web/web: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -5897,13 +5897,13 @@ importers: version: link:../../llm/llm 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) + version: link:../../../vendor/cordis packages/web/web-fetch-local: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -5916,13 +5916,13 @@ importers: version: link:../web 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) + version: link:../../../vendor/cordis packages/web/web-search-deepseek: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -5944,13 +5944,13 @@ importers: version: link:../web 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) + version: link:../../../vendor/cordis packages/web/web-search-exa: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -5960,13 +5960,13 @@ importers: version: link:../web 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) + version: link:../../../vendor/cordis packages/web/web-search-perplexity: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -5976,13 +5976,13 @@ importers: version: link:../web 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) + version: link:../../../vendor/cordis packages/workflow/tool-ralph: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -6028,13 +6028,13 @@ importers: version: link:../workflow-workerthread cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/workflow/tool-workflow: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6065,7 +6065,7 @@ importers: version: link:../workflow-workerthread 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) + version: link:../../../vendor/cordis packages/workflow/workflow: devDependencies: @@ -6086,13 +6086,13 @@ importers: version: link:../../core/session 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) + version: link:../../../vendor/cordis packages/workflow/workflow-workerthread: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6132,7 +6132,7 @@ importers: version: link:../workflow 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) + version: link:../../../vendor/cordis tsx: specifier: ^4.19.2 version: 4.22.4 @@ -6163,7 +6163,7 @@ importers: version: link:../../storage/storage-domain 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) + version: link:../../../vendor/cordis python/sdk-runtime: dependencies: @@ -6466,16 +6466,16 @@ importers: dependencies: '@cordisjs/plugin-include': specifier: ^1.0.4 - version: 1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.7) + version: link:../include '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../loader '@standard-schema/spec': specifier: ^1.1.0 version: 1.1.0 cosmokit: specifier: ^1.8.1 - version: 1.8.1 + version: link:../cosmokit vendor/cosmokit: {} @@ -6483,10 +6483,10 @@ importers: dependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../loader 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) + version: link:../cordis vendor/hmr: dependencies: @@ -6495,22 +6495,22 @@ importers: version: 7.29.7 '@cordisjs/plugin-timer': specifier: ^1.1.2 - version: 1.1.2(cordis@4.0.0-rc.7) + version: link:../timer chokidar: specifier: ^4.0.3 version: 4.0.3 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) + version: link:../cordis cosmokit: specifier: ^1.8.1 - version: 1.8.1 + version: link:../cosmokit picomatch: specifier: ^4.0.3 version: 4.0.4 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../schemastery devDependencies: '@types/babel__code-frame': specifier: ^7.27.0 @@ -6526,13 +6526,13 @@ importers: dependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../loader 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) + version: link:../cordis cosmokit: specifier: ^1.8.1 - version: 1.8.1 + version: link:../cosmokit js-yaml: specifier: ^4.1.0 version: 4.2.0 @@ -6541,10 +6541,10 @@ importers: dependencies: 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) + version: link:../cordis cosmokit: specifier: ^1.8.1 - version: 1.8.1 + version: link:../cosmokit node-addon-require-builtin: specifier: ^0.1.3 version: 0.1.3 @@ -6553,13 +6553,13 @@ importers: dependencies: 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) + version: link:../cordis cosmokit: specifier: ^1.8.1 - version: 1.8.1 + version: link:../cosmokit schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../schemastery supports-color: specifier: ^9.4.0 version: 9.4.0 @@ -6571,16 +6571,16 @@ importers: version: 1.1.0 cosmokit: specifier: ^1.8.1 - version: 1.8.1 + version: link:../cosmokit vendor/timer: dependencies: 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) + version: link:../cordis cosmokit: specifier: ^1.8.1 - version: 1.8.1 + version: link:../cosmokit website: devDependencies: @@ -6956,26 +6956,6 @@ packages: resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} engines: {node: '>= 20.12.0'} - '@cordisjs/plugin-include@1.0.4': - resolution: {integrity: sha512-b1Hm1wmue0v7d/jayoXoBjCV2J14XWTL5yyDZEYeL2L9HgcyTq6JbCw99ozSbei98uAbkwq/pBhimsp/HsySeg==} - peerDependencies: - '@cordisjs/plugin-loader': ^1.0.0-rc.4 - cordis: ^4.0.0-rc.5 - - '@cordisjs/plugin-loader@1.0.0-rc.5': - resolution: {integrity: sha512-084Wn2SzkFinbaASTq8blHOUqQt/oxZfX6gnrt0lnJ1CrystulFLL1+XVgF4o7lUMN9bHn4cfT1pMtkHprCtHw==} - peerDependencies: - cordis: ^4.0.0-rc.7 - node-addon-require-builtin: ^0.1.0 - peerDependenciesMeta: - node-addon-require-builtin: - optional: true - - '@cordisjs/plugin-timer@1.1.2': - resolution: {integrity: sha512-5z5C3Eewt8JzK9XGy5JgIoYFRqXPWZnT7hHFfuJMQNzSom6iEVeLXpYiMvqVqGfJicHA7IroaOjcLRf99sidrQ==} - peerDependencies: - cordis: ^4.0.0-rc.5 - '@csstools/color-helpers@6.1.0': resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} engines: {node: '>=20.19.0'} @@ -9350,18 +9330,6 @@ packages: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} - cordis@4.0.0-rc.7: - resolution: {integrity: sha512-5nm6ehrSfJhEUV659CctEvyNuBY/AXapw8+ZEw7YENztdzpiT+Ha8nIfkyhfyAgPtJns9aB5On5nzl9Sm6zHeQ==} - hasBin: true - peerDependencies: - '@cordisjs/plugin-include': ^1.0.4 - '@cordisjs/plugin-loader': ^1.0.0-rc.5 - peerDependenciesMeta: - '@cordisjs/plugin-include': - optional: true - '@cordisjs/plugin-loader': - optional: true - core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -9375,9 +9343,6 @@ packages: cose-base@2.2.0: resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} - cosmokit@1.8.1: - resolution: {integrity: sha512-PDBv4l90xZKrUsZ0vtoycgZpO/j4iFsqJXrAxsyBDsnQRI7ZMJXIjgDJsKNjd5L8jnVnnlrDCdhkFbTncgCVjQ==} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -11206,9 +11171,6 @@ packages: scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} - schemastery@3.18.0: - resolution: {integrity: sha512-Jw2uxjoyyqc/yeurmChUEc/jbi8GsrdXV/KmqRUDZXJAXAmrJiPsz8vKa17l/VckyzljHZ9oGaul443CQiXxtA==} - scslre@0.3.0: resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} engines: {node: ^14.0.0 || >=16.0.0} @@ -12463,33 +12425,6 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@cordisjs/plugin-include@1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.7)': - dependencies: - '@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) - cordis: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - cosmokit: 1.8.1 - js-yaml: 4.2.0 - - '@cordisjs/plugin-include@1.0.4(@cordisjs/plugin-loader@vendor+loader)(cordis@4.0.0-rc.7)': - dependencies: - '@cordisjs/plugin-loader': link:vendor/loader - cordis: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) - cosmokit: 1.8.1 - js-yaml: 4.2.0 - optional: true - - '@cordisjs/plugin-loader@1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3)': - dependencies: - cordis: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - cosmokit: 1.8.1 - optionalDependencies: - node-addon-require-builtin: 0.1.3 - - '@cordisjs/plugin-timer@1.1.2(cordis@4.0.0-rc.7)': - dependencies: - cordis: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - cosmokit: 1.8.1 - '@csstools/color-helpers@6.1.0': {} '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -14429,30 +14364,6 @@ snapshots: dependencies: is-what: 5.5.0 - cordis@4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5): - dependencies: - '@standard-schema/spec': 1.1.0 - cosmokit: 1.8.1 - optionalDependencies: - '@cordisjs/plugin-include': 1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.7) - '@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) - - cordis@4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader): - dependencies: - '@standard-schema/spec': 1.1.0 - cosmokit: 1.8.1 - optionalDependencies: - '@cordisjs/plugin-include': 1.0.4(@cordisjs/plugin-loader@vendor+loader)(cordis@4.0.0-rc.7) - '@cordisjs/plugin-loader': link:vendor/loader - - cordis@4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader): - dependencies: - '@standard-schema/spec': 1.1.0 - cosmokit: 1.8.1 - optionalDependencies: - '@cordisjs/plugin-include': link:vendor/include - '@cordisjs/plugin-loader': link:vendor/loader - core-util-is@1.0.3: {} cors@2.8.6: @@ -14468,8 +14379,6 @@ snapshots: dependencies: layout-base: 2.0.1 - cosmokit@1.8.1: {} - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -16809,11 +16718,6 @@ snapshots: dependencies: loose-envify: 1.4.0 - schemastery@3.18.0: - dependencies: - '@standard-schema/spec': 1.1.0 - cosmokit: 1.8.1 - scslre@0.3.0: dependencies: '@eslint-community/regexpp': 4.12.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index eb7a7c6322..751037b822 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,6 +16,10 @@ packages: # closure is what the exe bundles and what the Python runtime distributes. - python/sdk-runtime +# Vendored framework packages keep their upstream semver ranges, while local +# builds must resolve those matching names to this workspace's pinned sources. +linkWorkspacePackages: true + peerDependencyRules: allowedVersions: typescript: '>=5 <7' diff --git a/vendor/README.md b/vendor/README.md index 1ad2b94e41..4fc9f66cc9 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -2,7 +2,7 @@ This directory contains source-vendored copies of the Cordis framework and its foundation libraries. They are copied into this monorepo instead of being depended on via npm, so that the harness fully owns its framework layer (auditable, patchable, pinned). -All vendored packages keep their **original npm names** (they are resolved through pnpm workspaces) and are marked `private: true` — they are never published from this repo. Upstream MIT `LICENSE` files are preserved in each package directory. +All vendored packages keep their **original npm names** and are marked `private: true` — they are never published from this repo. `pnpm-workspace.yaml#linkWorkspacePackages` makes matching upstream semver ranges resolve these pinned workspaces, including imports from built `lib/`; disabling it substitutes npm copies behind the same names. Schemastery's manifest additionally declares a conditional `exports` map (import → `.mjs`, require → `.cjs`): pnpm links the directory itself, so without `exports` Node's ESM resolver would fall back to `main` and load the CJS entry whose lazy `require('cosmokit')` can race ESM loading of the same linked module under module-hook hosts (vitest). Upstream MIT `LICENSE` files are preserved in each package directory. This file covers the manifest, the local-modification log, and the procedure for **updating** an existing vendored package. To **add a new** one, see the cookbook guide: [docs/cookbook/adding-a-vendored-package.md](../docs/cookbook/adding-a-vendored-package.md). diff --git a/vendor/schemastery/package.json b/vendor/schemastery/package.json index 8433f35ec8..f23fac56db 100644 --- a/vendor/schemastery/package.json +++ b/vendor/schemastery/package.json @@ -7,6 +7,15 @@ "main": "lib/index.cjs", "module": "lib/index.mjs", "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "import": "./lib/index.mjs", + "require": "./lib/index.cjs" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, "files": [ "lib/index.mjs", "lib/index.cjs", From 2fe205f01aed6926796abf62ed043b2d709f4d80 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 05:18:31 +0800 Subject: [PATCH 11/23] fix(cordis): clear omitted include patches --- packages/ui/app-boot/tests/config-reload.spec.ts | 6 +++--- vendor/README.md | 2 +- vendor/include/src/index.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/ui/app-boot/tests/config-reload.spec.ts b/packages/ui/app-boot/tests/config-reload.spec.ts index 1a236e906b..d9f4ffa830 100644 --- a/packages/ui/app-boot/tests/config-reload.spec.ts +++ b/packages/ui/app-boot/tests/config-reload.spec.ts @@ -328,9 +328,9 @@ describe('include refresh with overlay patches', () => { await ctx.loader.await() expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' }) - // Removing every patch must revert to the file's own values: patching - // may not bake earlier patch results into the cached parse. - await entry.update({ config: { path: './base.yml', patches: [] } }) + // Omitting the patch list must remove the overlay rather than reuse the + // Include's previous config through a default parameter. + await entry.update({ config: { path: './base.yml' } }) await ctx.loader.await() expect(entryConfig(ctx, 'noop')).toEqual({ value: 'edited-2' }) } finally { diff --git a/vendor/README.md b/vendor/README.md index 4fc9f66cc9..8a1bb3ca8d 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. `Fiber.update()` returns its `internal/update` waterfall result, allowing Loader callers to await a restart while preserving synchronous config validation. 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. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates run sequentially, undo earlier changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. +8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates run sequentially, undo earlier changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. 9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/ui/app-boot/tests/hmr-config.spec.ts`. 10. **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. 11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` 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/config/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`. diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 43860dfd56..a13d273bc2 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -228,7 +228,7 @@ export class Include extends EntryTree { return { content, data } } - private applyPatches(data: EntryOptions[], patches = this.config.patches): EntryOptions[] { + private applyPatches(data: EntryOptions[], patches?: PatchOptions[]): EntryOptions[] { return applyEntryPatches(data, patches, (message, ...args) => { this.ctx.root.logger?.('loader').warn(message, ...args) }) @@ -268,7 +268,7 @@ export class Include extends EntryTree { } private async apply(candidate: ReadCandidate) { - const data = this.applyPatches(candidate.data) + const data = this.applyPatches(candidate.data, this.config.patches) await this.root.update(data) this.content = candidate.content this.data = candidate.data From 0a297c39d3c005ca069d1b7bc95f606ca1e4e7b3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 06:23:16 +0800 Subject: [PATCH 12/23] fix(cordis): preserve concurrent loader composition --- .../2026-07-20-config-hot-reload-resilience.i18n.yaml | 4 ++-- .../bug-fix/2026-07-20-config-hot-reload-resilience.md | 6 +++--- .../bug-fix/2026-07-20-config-hot-reload-resilience.zh.md | 6 +++--- examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts | 4 +++- vendor/README.md | 2 +- vendor/loader/src/config/group.ts | 7 ++++++- 6 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml index f6a6429e64..6f4a6d363d 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.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/bug-fix/2026-07-20-config-hot-reload-resilience.md -2026-07-20-config-hot-reload-resilience.md: f3c36f8055179870c19c9d1ce99c3533fe602aa6 -2026-07-20-config-hot-reload-resilience.zh.md: 72ef2ebfa582dcc614198ed094c9b58ea1713460 +2026-07-20-config-hot-reload-resilience.md: 0f15bb0aaacb6e06c416cbe35b44155279497eee +2026-07-20-config-hot-reload-resilience.zh.md: 8a185c1915b5247150d8bb1dd5c42d69bd4f2a35 diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md index f3c36f8055..0f15bb0aaa 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md @@ -14,7 +14,7 @@ The vendored Cordis lifecycle and Loader plugins provide an awaited, compensatin `Fiber.update()` returns its `internal/update` waterfall result. Config validation remains synchronous, while the default continuation returns the restart promise. Loader entry updates can therefore distinguish validation, import, application, and rollback failure from successful lifecycle settlement. `EntryTree.await()` rechecks service-gated fibers after Loader tasks drain and rejects settled failures; a fiber waiting on an absent service remains a valid pending entry rather than making settlement hang. -Loader imports a changed module name before disposing the active fiber. Candidate application is awaited; a failure disposes candidate effects and restores the prior plugin or config. Group reconciliation is sequential and restores earlier changed entries, additions, removals, and moves before rejecting. Persistence occurs only after successful programmatic mutation. This is a compensating transaction: lifecycle effects may be briefly visible, and a failed rollback is reported as an `AggregateError` rather than misrepresented as a retained tree. +Loader imports a changed module name before disposing the active fiber. Candidate application is awaited; a failure disposes candidate effects and restores the prior plugin or config. Group reconciliation starts candidates concurrently, awaits every outcome, and restores changed entries, additions, removals, and moves before rejecting. Persistence occurs only after successful programmatic mutation. This is a compensating transaction: lifecycle effects may be briefly visible, and a failed rollback is reported as an `AggregateError` rather than misrepresented as a retained tree. Include reads and validates detached candidate content, applies patches to a clone, reconciles the Loader tree, and only then commits cached content and parsed data. `refresh()` rejects to its caller after a parse, validation, application, or rollback failure. Initial load remains fail-loud; only an absent file may use `initial`. A non-array YAML/JSON result is invalid, and both file refresh and Include-config update re-apply patches without mutating the cached parse. @@ -26,7 +26,7 @@ HMR contains live refresh rejection. Its `registerConfig(filename, refresh)` met **Restart the process for every config edit.** Rejected because Cordis effects already provide reversible plugin lifecycle, and a syntax error or failed optional plugin must not discard live sessions merely to recover the prior composition. -**Promise invisible atomic replacement.** Rejected because arbitrary plugin effects cannot be snapshotted. Sequential application plus explicit compensation provides a stable final result without claiming that observers cannot see intermediate lifecycle transitions. +**Promise invisible atomic replacement.** Rejected because arbitrary plugin effects cannot be snapshotted. Awaited application plus explicit compensation provides a stable final result without claiming that observers cannot see intermediate lifecycle transitions. ## Consequences @@ -38,4 +38,4 @@ HMR contains live refresh rejection. Its `registerConfig(filename, refresh)` met ## Testing -`packages/ui/app-boot/tests/config-reload.spec.ts` boots real temporary Loader/Include trees and covers parse and shape rejection, import-before-dispose, plugin/config restoration, multi-entry rollback, ancestor disablement, overlay convergence, option identity, failed direct-update persistence, and failed programmatic moves. `packages/ui/app-boot/tests/hmr-config.spec.ts` covers existing and missing exact paths, add/change/removal, serialized coalescing, disposal drainage, non-`Error` normalization, failure broadcast, and rejecting-observer containment. `packages/host/webserver/tests/webserver.spec.ts` proves a service-gated startup failure rejects Loader composition with its bind diagnostic, and `packages/typert/loader/tests/loader.spec.ts` exercises awaited programmatic removal through a real Loader consumer. +`packages/ui/app-boot/tests/config-reload.spec.ts` boots real temporary Loader/Include trees and covers parse and shape rejection, import-before-dispose, plugin/config restoration, multi-entry rollback, ancestor disablement, overlay convergence, option identity, failed direct-update persistence, and failed programmatic moves. `packages/ui/app-boot/tests/hmr-config.spec.ts` covers existing and missing exact paths, add/change/removal, serialized coalescing, disposal drainage, non-`Error` normalization, failure broadcast, and rejecting-observer containment. `packages/host/webserver/tests/webserver.spec.ts` proves a service-gated startup failure rejects Loader composition with its bind diagnostic, `packages/typert/loader/tests/loader.spec.ts` exercises awaited programmatic removal through a real Loader consumer, and the ACP `pty-tools` snapshot guards concurrent composition from reordering equal-priority prompt sections. diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md index 72ef2ebfa5..8a185c1915 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md @@ -14,7 +14,7 @@ vendor 中的 Cordis 生命周期和 Loader 插件提供可等待、带补偿的 `Fiber.update()` 返回其 `internal/update` waterfall(瀑布式事件)的结果。配置校验保持同步,而默认 continuation 返回重启 promise。因此,Loader 配置项更新可以区分校验、导入、应用和回滚失败,以及生命周期成功完成。`EntryTree.await()` 会在 Loader 任务排空后重新检查受服务门控的 fiber,并在 fiber 已结算为失败时 reject;等待缺失服务的 fiber 仍是有效的 pending 配置项,不会让结算挂起。 -Loader 会先导入变化后的模块名,再 dispose(资源释放)活动 fiber。它会 await 候选项的应用;若失败,则 dispose 候选项的 effect,并恢复先前的插件或配置。组内对账按顺序进行,并会在拒绝前恢复此前已变更的配置项、添加项、移除项和移动项。只有程序化变更成功后才会持久化。这是一种补偿事务:生命周期 effect 可能短暂可见;回滚失败会报告为 `AggregateError`,而不会被误称为树已保留。 +Loader 会先导入变化后的模块名,再 dispose(资源释放)活动 fiber。它会 await 候选项的应用;若失败,则 dispose 候选项的 effect,并恢复先前的插件或配置。组内对账会并发启动各候选项,等待每项结果,并会在拒绝前恢复已变更的配置项、添加项、移除项和移动项。只有程序化变更成功后才会持久化。这是一种补偿事务:生命周期 effect 可能短暂可见;回滚失败会报告为 `AggregateError`,而不会被误称为树已保留。 Include 读取并校验尚未提交的候选内容,把补丁应用到其副本,对账 Loader 树,然后才提交缓存内容和解析数据。解析、校验、应用或回滚失败后,`refresh()` 会向调用方 reject。初始加载继续快速失败;只有文件不存在时才可以使用 `initial`。YAML/JSON 结果若不是数组即为无效;文件刷新和 Include 配置更新都会重新应用补丁,且不修改缓存的解析结果。 @@ -26,7 +26,7 @@ HMR 收容实时刷新 rejection。其 `registerConfig(filename, refresh)` 方 **每次编辑配置都重启进程。** 已否决,因为 Cordis effect 已经提供可逆的插件生命周期,而语法错误或可选插件失败不应只为恢复先前的组合就丢弃正在进行的会话。 -**承诺不可见的原子替换。** 已否决,因为任意插件 effect 无法制作快照。按顺序应用并显式补偿可以得到稳定的最终结果,同时不会声称观察者看不到中间生命周期转换。 +**承诺不可见的原子替换。** 已否决,因为任意插件 effect 无法制作快照。等待应用完成并显式补偿可以得到稳定的最终结果,同时不会声称观察者看不到中间生命周期转换。 ## Consequences @@ -38,4 +38,4 @@ HMR 收容实时刷新 rejection。其 `registerConfig(filename, refresh)` 方 ## Testing -`packages/ui/app-boot/tests/config-reload.spec.ts` 启动真实的临时 Loader/Include 树,并覆盖对解析和形状错误的拒绝、先导入再 dispose、插件/配置恢复、多配置项回滚、祖先禁用、overlay 收敛、option 对象身份、失败的直接更新不持久化以及失败的程序化移动。`packages/ui/app-boot/tests/hmr-config.spec.ts` 覆盖现有和缺失的确切路径、添加/变更/移除、串行化合并、dispose 排空、非 `Error` 值的规范化、失败广播以及对发生 rejection 的观察者的收容。`packages/host/webserver/tests/webserver.spec.ts` 证明受服务门控的启动失败会让 Loader 组合以其 bind 诊断 reject;`packages/typert/loader/tests/loader.spec.ts` 则通过真实 Loader 消费方演练可等待的程序化移除。 +`packages/ui/app-boot/tests/config-reload.spec.ts` 启动真实的临时 Loader/Include 树,并覆盖对解析和形状错误的拒绝、先导入再 dispose、插件/配置恢复、多配置项回滚、祖先禁用、overlay 收敛、option 对象身份、失败的直接更新不持久化以及失败的程序化移动。`packages/ui/app-boot/tests/hmr-config.spec.ts` 覆盖现有和缺失的确切路径、添加/变更/移除、串行化合并、dispose 排空、非 `Error` 值的规范化、失败广播以及对发生 rejection 的观察者的收容。`packages/host/webserver/tests/webserver.spec.ts` 证明受服务门控的启动失败会让 Loader 组合以其 bind 诊断 reject;`packages/typert/loader/tests/loader.spec.ts` 则通过真实 Loader 消费方演练可等待的程序化移除;ACP(Agent Client Protocol)的 `pty-tools` 快照会防止并发组合改变同优先级提示词段的顺序。 diff --git a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts index cee30b4328..a713b27163 100644 --- a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts +++ b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts @@ -184,6 +184,8 @@ describe('jsonrpc-agent keyless smoke', () => { expect(exitCode, stderr).toBe(1) expect(stdout).toBe('') - expect(stderr).toContain('plugin(s) failed to load: @deepseek-ai/dsh-jsonrpc') + expect(stderr).toContain('plugin tree failed to load') + expect(stderr).toContain('failed to apply loader entry jsonrpc (@deepseek-ai/dsh-jsonrpc)') + expect(stderr).toContain('sometimes') }, 30_000) }) diff --git a/vendor/README.md b/vendor/README.md index 8a1bb3ca8d..ad35767c33 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. `Fiber.update()` returns its `internal/update` waterfall result, allowing Loader callers to await a restart while preserving synchronous config validation. 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. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates run sequentially, undo earlier changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. +8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, undo changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. 9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/ui/app-boot/tests/hmr-config.spec.ts`. 10. **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. 11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` 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/config/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`. diff --git a/vendor/loader/src/config/group.ts b/vendor/loader/src/config/group.ts index cdd613caf6..8b96187275 100644 --- a/vendor/loader/src/config/group.ts +++ b/vendor/loader/src/config/group.ts @@ -68,7 +68,12 @@ export class EntryGroup { const newMap = Object.fromEntries(config.map(options => [options.id, options])) try { - for (const options of config) await this.create(options) + const outcomes = await Promise.allSettled(config.map(options => this.create(options))) + const failures = outcomes + .filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') + .map(outcome => outcome.reason) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'loader entries failed to apply') for (const id of Object.keys(oldMap)) { if (!newMap[id]) await this.remove(id, true) } From f941ba1b5fe93149a0262972fb63478cfaf652a8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:41:43 +0800 Subject: [PATCH 13/23] fix(review): label host-preparation failures and gate vendored lockfile links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ds-review-bot round 1: boot() now throws `host preparation failed` when prepare() rejects before any config-tree entry mounts (the plugin-tree label overstated), and the new hygiene gate verify-vendored-links pins the linkWorkspacePackages fix — every vendored package name in pnpm-lock.yaml must resolve to a workspace link with no registry copy alongside. --- package.json | 3 +- packages/ui/app-boot/src/index.ts | 13 +++- packages/ui/app-boot/tests/app-boot.spec.ts | 6 +- scripts/verify-vendored-links.ts | 72 +++++++++++++++++++++ vendor/README.md | 2 +- 5 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 scripts/verify-vendored-links.ts diff --git a/package.json b/package.json index a5f76a0612..c1013c23c6 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "verify-package-readme-limitations": "tsx scripts/verify-package-readme-limitations.ts", "verify-node-next-types": "tsx scripts/verify-node-next-types.ts", "verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts", + "verify-vendored-links": "tsx scripts/verify-vendored-links.ts", "verify-cordis-config": "tsx scripts/verify-cordis-config.ts", "verify-client-domain-graph": "tsx scripts/verify-client-domain-graph.ts", "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", @@ -100,7 +101,7 @@ "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "tsx scripts/run-gates.ts doc-sync", - "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", + "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure && pnpm run verify-vendored-links", "dsh": "node --import tsx/esm apps/cli/src/bin.ts", "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", "demo:tui": "node --import tsx/esm apps/cli/src/bin.ts", diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 917eb777e8..7897c5fb89 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -445,7 +445,9 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro * @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, or as soon as a * surface disposed the tree while startup was still in flight. - * @throws a labelled load error after disposing the partial context. + * @throws a labelled error after disposing the partial context — `host + * preparation failed` when `prepare` threw before any config-tree entry + * mounted, `plugin tree failed to load` afterwards. */ export async function boot( binName: string, @@ -454,12 +456,16 @@ export async function boot( prepare?: (ctx: Context) => Promise | void, ): Promise { const ctx = new Context() + // Two failure labels: `prepare` runs before any config-tree entry mounts, + // so its failure is host setup, not the plugin tree. + let stage = 'host preparation failed' try { ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' ctx.provide('dshHomePath', dshHomePath) await ctx.plugin(Loader) ctx.loader.builtins.include = Include await prepare?.(ctx) + stage = 'plugin tree failed to load' // Pinned id: the bootstrap include is app glue, not a config row, and its // id appears in Loader failure chains — a random id would make startup // diagnostics unstable across runs (and snapshot fixtures). @@ -485,6 +491,9 @@ export async function boot( await assertEntriesActivated(ctx, binName) return ctx } catch (cause) { + // Root-fiber disposal contains cleanup failures per observer (Cordis + // fiber.ts hardening) and a repeated call returns the settled single-shot + // result, so this await cannot reject and replace `cause`. await ctx.fiber.dispose() const detail = cause instanceof Error ? cause.message : String(cause) // The transactional Loader wraps a failing entry apply in one message per @@ -495,7 +504,7 @@ export async function boot( let deepest: unknown = cause while (deepest instanceof Error && deepest.cause !== undefined) deepest = deepest.cause const stack = deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : '' - throw new Error(`${binName}: plugin tree failed to load: ${detail}${stack}`, { cause }) + throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause }) } } diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 03dd93a365..610f93b3d5 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -335,7 +335,7 @@ describe('boot', () => { }) await expect(task).rejects.toMatchObject({ - message: `${NAME}: plugin tree failed to load: ${failure}`, + message: `${NAME}: host preparation failed: ${failure}`, cause: failure, }) expect(disposed).toBe(true) @@ -418,9 +418,9 @@ describe('boot', () => { const deepest = new Error('stackless deep failure') delete (deepest as { stack?: string }).stack await expect(boot(NAME, join(dir, 'cordis.yml'), undefined, () => { - throw new Error('host preparation failed', { cause: deepest }) + throw new Error('wrapped setup failure', { cause: deepest }) })).rejects.toThrow( - `${NAME}: plugin tree failed to load: host preparation failed\nstackless deep failure`, + `${NAME}: host preparation failed: wrapped setup failure\nstackless deep failure`, ) }) diff --git a/scripts/verify-vendored-links.ts b/scripts/verify-vendored-links.ts new file mode 100644 index 0000000000..93b8390157 --- /dev/null +++ b/scripts/verify-vendored-links.ts @@ -0,0 +1,72 @@ +/** + * Verify that pnpm-lock.yaml resolves every vendored package name to its + * workspace `link:` — never a registry copy. `linkWorkspacePackages: true` + * (pnpm-workspace.yaml) makes matching upstream semver ranges resolve to the + * pinned vendored sources; a registry copy of the same name coexisting with + * the vendored one silently forks the framework layer (vendor/README.md). + */ +import { readdir, readFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import * as yaml from 'js-yaml' + +const root = resolve(import.meta.dirname, '..') + +async function vendoredNames(): Promise> { + const names = new Set() + for (const entry of await readdir(join(root, 'vendor'), { withFileTypes: true })) { + if (!entry.isDirectory()) continue + let manifest: { name?: string } + try { + manifest = JSON.parse(await readFile(join(root, 'vendor', entry.name, 'package.json'), 'utf8')) as { name?: string } + } catch { + continue // not a package directory (e.g. vendor/README.md siblings) + } + if (manifest.name !== undefined) names.add(manifest.name) + } + return names +} + +interface Lockfile { + importers?: Record> + packages?: Record + snapshots?: Record +} + +const names = await vendoredNames() +if (names.size === 0) throw new Error('verify-vendored-links: no vendored package manifests found under vendor/') +const lockfile = yaml.load(await readFile(join(root, 'pnpm-lock.yaml'), 'utf8')) as Lockfile + +const violations: string[] = [] + +// Importer resolutions: every dependency entry naming a vendored package must +// resolve to a link:, or the build silently uses a registry copy. +for (const [importer, sections] of Object.entries(lockfile.importers ?? {})) { + for (const [section, dependencies] of Object.entries(sections)) { + if (typeof dependencies !== 'object' || dependencies === null) continue + for (const [dependency, entry] of Object.entries(dependencies as Record)) { + if (!names.has(dependency)) continue + const version = entry.version ?? '' + if (!version.startsWith('link:')) { + violations.push(`${importer} ${section}.${dependency} resolves to ${JSON.stringify(version)} (expected link:)`) + } + } + } +} + +// Package/snapshot keys: a registry copy materializes as a `@` +// key; vendored names must never appear there at all. +for (const section of ['packages', 'snapshots'] as const) { + for (const key of Object.keys(lockfile[section] ?? {})) { + const atIndex = key.lastIndexOf('@') + if (atIndex <= 0) continue + const packageName = key.slice(0, atIndex) + if (names.has(packageName)) violations.push(`${section} entry ${key} is a registry copy of a vendored package`) + } +} + +if (violations.length > 0) { + console.error(`verify-vendored-links: ${String(violations.length)} lockfile resolution(s) bypass the vendored workspaces:`) + for (const violation of violations) console.error(` - ${violation}`) + process.exit(1) +} +console.log(`verify-vendored-links: all ${String(names.size)} vendored package names resolve to workspace links.`) diff --git a/vendor/README.md b/vendor/README.md index ad35767c33..443c78e278 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -2,7 +2,7 @@ This directory contains source-vendored copies of the Cordis framework and its foundation libraries. They are copied into this monorepo instead of being depended on via npm, so that the harness fully owns its framework layer (auditable, patchable, pinned). -All vendored packages keep their **original npm names** and are marked `private: true` — they are never published from this repo. `pnpm-workspace.yaml#linkWorkspacePackages` makes matching upstream semver ranges resolve these pinned workspaces, including imports from built `lib/`; disabling it substitutes npm copies behind the same names. Schemastery's manifest additionally declares a conditional `exports` map (import → `.mjs`, require → `.cjs`): pnpm links the directory itself, so without `exports` Node's ESM resolver would fall back to `main` and load the CJS entry whose lazy `require('cosmokit')` can race ESM loading of the same linked module under module-hook hosts (vitest). Upstream MIT `LICENSE` files are preserved in each package directory. +All vendored packages keep their **original npm names** and are marked `private: true` — they are never published from this repo. `pnpm-workspace.yaml#linkWorkspacePackages` makes matching upstream semver ranges resolve these pinned workspaces, including imports from built `lib/`; disabling it substitutes npm copies behind the same names. The `hygiene` gate `verify-vendored-links` asserts every vendored name resolves to a workspace `link:` in `pnpm-lock.yaml` with no registry copy alongside. Schemastery's manifest additionally declares a conditional `exports` map (import → `.mjs`, require → `.cjs`): pnpm links the directory itself, so without `exports` Node's ESM resolver would fall back to `main` and load the CJS entry whose lazy `require('cosmokit')` can race ESM loading of the same linked module under module-hook hosts (vitest). Upstream MIT `LICENSE` files are preserved in each package directory. This file covers the manifest, the local-modification log, and the procedure for **updating** an existing vendored package. To **add a new** one, see the cookbook guide: [docs/cookbook/adding-a-vendored-package.md](../docs/cookbook/adding-a-vendored-package.md). From fd4d3699076071e5de3e965d5160400e9a57fabc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 03:45:49 +0800 Subject: [PATCH 14/23] feat(cordis): add repository package cache --- ...-manager-native-repository-cache.i18n.yaml | 6 + ...package-manager-native-repository-cache.md | 47 +++++ ...kage-manager-native-repository-cache.zh.md | 47 +++++ ...07-17-sdk-follow-up-capabilities.i18n.yaml | 6 +- .../2026-07-17-sdk-follow-up-capabilities.md | 2 + ...026-07-17-sdk-follow-up-capabilities.zh.md | 2 + THIRD_PARTY_NOTICES.md | 1 + .../app-boot/tests/repository-cache.spec.ts | 144 +++++++++++++ pnpm-lock.yaml | 10 + tsconfig.base.json | 1 + vendor/README.md | 5 +- vendor/loader/package.json | 8 +- vendor/loader/src/repository.ts | 191 ++++++++++++++++++ vendor/loader/tsdown.config.ts | 18 ++ 14 files changed, 482 insertions(+), 6 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.md create mode 100644 .agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.zh.md create mode 100644 packages/ui/app-boot/tests/repository-cache.spec.ts create mode 100644 vendor/loader/src/repository.ts create mode 100644 vendor/loader/tsdown.config.ts diff --git a/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.i18n.yaml new file mode 100644 index 0000000000..6c888c7cad --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.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-30-package-manager-native-repository-cache.md +2026-07-30-package-manager-native-repository-cache.md: f8a6706065a936ca4a9abf2a50d266a60f09b252 +2026-07-30-package-manager-native-repository-cache.zh.md: b1fea3d655f8d7aeb466744dc27bbf4ba69993ec diff --git a/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.md b/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.md new file mode 100644 index 0000000000..f8a6706065 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.md @@ -0,0 +1,47 @@ +# Agent Note: Package-manager-native repository cache + +Status: implemented + +English | [中文](2026-07-30-package-manager-native-repository-cache.zh.md) + +## Problem + +A standalone Harness app cannot rely on a developer-owned SDK project to declare and install repository dependencies. Loading a configured GitHub repository therefore needs a persistent fetch, preparation, and cache boundary, but implementing Git transport, hosted-source syntax, package preparation, and a content store inside DSH would duplicate a package manager. Requiring a separately installed package manager would make a config-only feature depend on host setup. + +The cache also needs an update identity. A mutable branch name cannot both remain permanently cached and reflect later commits without an independent refresh protocol. + +## Decision + +Vendored `@cordisjs/plugin-loader/repository` exports `RepositoryCache`, a generic Node-only package helper with no DSH plugin-format knowledge. Keeping it on a subpath prevents browser consumers of the Loader's main entry from traversing Node filesystem and child-process imports. The caller supplies a package-manager-native source specifier and a cache root. DSH-specific callers own accepted source syntax, path selection, and the cache-root location; the [SDK project dependency workflow](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md#external-cordis-plugin-installation) remains a separate path owned by the developer project's selected package manager. + +The Loader carries an exact runtime dependency on `pnpm@11.7.0` and invokes that package's JavaScript entry with the current Node executable. It never discovers a global executable or delegates through Corepack. Each cache miss creates an isolated project with one dependency named `repository`; pnpm owns Git/GitHub resolution, fetching, its content-addressed store, dependency installation, and lifecycle scripts in the repository's dependency graph. + +The isolated workspace sets `dangerouslyAllowAllBuilds: true`. A configured repository and its dependency graph are trusted executable code: lifecycle scripts may run before DSH reads any declared assets. The child receives ordinary host process state needed by Git and pnpm, but ambient credential-shaped (`KEY`, `PASSWORD`, `SECRET`, `TOKEN`) variables are removed. No OAuth, token forwarding, or private-repository authentication contract is added. + +The SHA-256 of the exact specifier names the cache entry. Concurrent same-process requests share one task. Installation occurs in a sibling temporary directory; only a successful install with a package directory and marker is atomically renamed into the final key. Failed staging is removed, and a competing process's already-published valid entry wins. A later process validates the marker and package directory before returning the stable `node_modules/repository` path. + +An identical specifier permanently reuses its published entry. The caller changes the ref or another part of the specifier to request a new generation; the cache does not poll remotes, reinterpret mutable refs, expire entries, or garbage-collect old generations. + +## Alternatives considered + +**Implement GitHub download, archive extraction, preparation, and caching directly.** Rejected under the [dependency policy](../process/2026-07-26-dependencies-over-hand-rolling.md): pnpm already owns hosted Git syntax, Git execution, lifecycle policy, and a shared content store. A second resolver would add more code while still needing package semantics. + +**Require `pnpm` on `PATH` or invoke Corepack.** Rejected because changing one app config must be sufficient on every supported installation. Pinning and shipping the CLI also makes the preparation policy reviewable and independent of the host's package-manager version. + +**Resolve a branch or tag again on every startup.** Rejected because it turns startup into a network refresh, changes code without a config diff, and makes rollback depend on remote state. Explicit ref changes preserve auditability even when a user deliberately chooses a mutable ref. + +**Disable repository lifecycle scripts.** Rejected because common plugin repositories need a declarative `prepare` step to validate and package their plugin subdirectory. The trust boundary is explicit configuration of executable source, not an incomplete illusion that only static files can run. + +**Introduce a Cordis repository service.** Rejected because cache lookup has no runtime contribution registry or provider variation. A small helper lets the later host own Cordis lifecycle and HMR without adding a service seam prematurely. + +## Consequences + +- Standalone apps carry pnpm's approximately 18.6 MB unpacked runtime instead of requiring a global tool or owning a Git/package implementation. +- A repository author may use ordinary package preparation, and a malicious configured repository or dependency can execute code with the scrubbed child environment and the user's filesystem authority. +- Exact specifiers make startup deterministic after the first successful install; changing cached code requires a config/ref change. +- Failed installs leave no published cache entry and may be retried. Published corruption fails loud instead of silently reinstalling under the same identity. +- Cache generations consume disk until a future explicit cache-management policy removes them. + +## Testing + +`packages/ui/app-boot/tests/repository-cache.spec.ts` covers same-process single-flight, cross-instance cache reuse, exact-specifier separation, failed-stage cleanup and retry, and boundary validation. Its real local-Git case invokes the bundled pnpm, runs the fixture repository's `prepare` script, and reads the prepared file from the installed cache entry without network access. diff --git a/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.zh.md b/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.zh.md new file mode 100644 index 0000000000..b1fea3d655 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.zh.md @@ -0,0 +1,47 @@ +# Agent Note: 包管理器原生仓库缓存 + +Status: implemented + +[English](2026-07-30-package-manager-native-repository-cache.md) | 中文 + +## 问题 + +独立运行的 Harness 应用不能依赖开发者自有的 SDK 工程来声明并安装仓库依赖。因此,加载配置中的 GitHub 仓库需要一道持久的获取、准备与缓存边界;但如果在 DSH 内实现 Git 传输、托管来源语法、包(package)准备流程和内容存储,就会重复实现包管理器。若要求用户另行安装包管理器,则只需修改配置即可使用的功能还会依赖宿主环境的额外配置。 + +缓存还需要明确更新标识。若没有独立的刷新协议,可变分支名无法既永久缓存,又反映后续 commit。 + +## 决策 + +vendor 中的 `@cordisjs/plugin-loader/repository` 导出 `RepositoryCache`:一个不包含 DSH 插件格式知识、仅限 Node 使用的通用包辅助工具。把它保留在子路径上,可以避免 Loader 主入口的浏览器消费方在解析依赖时遍历到 Node 文件系统和子进程 import。调用方提供包管理器原生的来源 specifier 和缓存根目录。DSH 专属调用方负责规定可接受的来源语法、路径选择与缓存根目录位置;[SDK 工程依赖工作流](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md#external-cordis-plugin-installation)仍是另一条路径,由开发者工程选定的包管理器负责。 + +Loader 将 `pnpm@11.7.0` 作为固定版本的运行时依赖,并使用当前 Node 可执行文件调用该包的 JavaScript 入口。它绝不探测全局可执行文件,也不经 Corepack 调用。每次缓存未命中都会创建一个隔离工程,其中只有一个名为 `repository` 的依赖;Git 与 GitHub 来源的解析和获取、pnpm 自身的内容寻址 store、依赖安装,以及仓库依赖图中的生命周期脚本均由 pnpm 负责。 + +隔离工作区设置 `dangerouslyAllowAllBuilds: true`。用户配置的仓库及其依赖图都属于受信任的可执行代码:DSH 读取任何已声明资产之前,生命周期脚本就可能运行。子进程会收到 Git 与 pnpm 所需的常规宿主进程状态,但会移除环境中名称形似凭据(`KEY`、`PASSWORD`、`SECRET`、`TOKEN`)的变量。该机制不新增 OAuth、token 转发或私有仓库认证契约。 + +缓存项以精确 specifier 的 SHA-256 命名。同一进程内针对相同 specifier 的并发请求共享一项任务。安装在同级临时目录中进行;只有安装成功且存在包目录和标记时,系统才会把暂存目录原子重命名为最终键对应的目录。失败的暂存目录会被删除;如果另一进程已发布有效项,则以该项为准。后续进程会先校验标记与包目录,再返回稳定的 `node_modules/repository` 路径。 + +相同的 specifier 会永久复用已发布项。调用方通过修改 ref 或 specifier 的其他部分来请求新的缓存代次;缓存不会轮询远端、重新解释可变 ref、让条目过期,也不会垃圾回收旧代次。 + +## 曾考虑的替代方案 + +**直接实现 GitHub 下载、归档解压、准备与缓存。** 根据[依赖政策](../process/2026-07-26-dependencies-over-hand-rolling.md)不予采纳:pnpm 已负责托管 Git 语法、Git 执行、生命周期政策和共享内容存储。第二套解析器会增加更多代码,却仍需实现包语义。 + +**要求 `pnpm` 位于 `PATH` 上,或调用 Corepack。** 不予采纳:在每种受支持的安装形态中,只修改一份应用配置就必须足以启用该功能。固定并随应用分发 CLI(命令行界面)还能使准备政策可供评审,并与宿主的包管理器版本无关。 + +**每次启动都重新解析分支或 tag。** 不予采纳:这会把启动变成网络刷新,在配置 diff 未变化时更改代码,并让回滚依赖远端状态。即使用户有意选择可变 ref,显式修改 ref 仍能保持可审计性。 + +**禁用仓库生命周期脚本。** 不予采纳:常见插件仓库需要声明式 `prepare` 步骤来校验并打包插件子目录。信任边界是显式配置可执行来源,而不是营造一种不完整的假象,仿佛只有静态文件能够运行。 + +**引入 Cordis 仓库服务。** 不予采纳:缓存查找没有运行时贡献注册表,也不存在提供方变体。小型 helper 让后续宿主负责 Cordis 生命周期与 HMR(热模块替换),无需过早新增服务 seam。 + +## 后果 + +- 独立应用随附 pnpm 约 18.6 MB 的解压后运行时,不要求全局工具,也无需自行实现 Git 与包处理。 +- 仓库作者可以使用常规包准备流程;恶意的已配置仓库或依赖可以在经过上述清理的子进程环境中,以用户的文件系统权限执行代码。 +- 精确 specifier 使首次安装成功后的启动具有确定性;更改缓存代码必须修改配置或 ref。 +- 安装失败不会留下已发布缓存项,可以再次重试。已发布缓存损坏时会明确报错,而不会在同一标识下静默重装。 +- 缓存代次会持续占用磁盘,直到未来有明确的缓存管理政策将其移除。 + +## 测试 + +`packages/ui/app-boot/tests/repository-cache.spec.ts` 覆盖同进程 single-flight、跨实例缓存复用、精确 specifier 隔离、失败暂存清理与重试,以及边界校验。其真实本地 Git 用例会调用随附的 pnpm,运行 fixture(测试前置数据)仓库的 `prepare` 脚本,并在不访问网络的情况下,从已安装缓存项中读取准备后的文件。 diff --git a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml index 8b70484312..fab3ecc2b1 100644 --- a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.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-17-sdk-follow-up-capabilities.md: 0f3ada6bdbb4ce933d14602cf59be9a51640e61c -2026-07-17-sdk-follow-up-capabilities.zh.md: d0d0b3e6bcdf192e64f003dc9f6e90cc2bdb060b +# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md +2026-07-17-sdk-follow-up-capabilities.md: 88d5d2f9bd1ce01c20177bcaee5bbe6b434bb978 +2026-07-17-sdk-follow-up-capabilities.zh.md: 998b7ec3cfddafe40537908fb61aa6d7e6f90418 diff --git a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md index 0f3ada6bdb..88d5d2f9bd 100644 --- a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md +++ b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md @@ -47,6 +47,8 @@ The repository ships a thin `SKILL.md` that teaches an agent to construct the st The package manager owns source parsing, version or commit resolution, integrity data, lockfile updates, and any build policy. The SDK does not download or unpack a second copy through giget or pacote. An external plugin remains a dependency under `node_modules`; local plugin scaffolding remains a separate project-creation concern. +This proposal concerns dependencies of developer-owned SDK projects. Standalone app repository caching, its bundled-pnpm policy, and its explicit preparation trust boundary are owned by the [package-manager-native repository cache](../../implemented/architecture/2026-07-30-package-manager-native-repository-cache.md). + ## Launcher telemetry ### Consent and collection diff --git a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md index d0d0b3e6bc..998b7ec3cf 100644 --- a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md +++ b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md @@ -47,6 +47,8 @@ Create 和 config 使用相同的功能计划形状。create 通过上述命令 包管理器负责来源解析、版本或 commit 解析、`integrity` 数据、lockfile 更新和构建策略。SDK 不再通过 giget 或 pacote 下载、解压第二份副本。外部插件是 `node_modules` 下的依赖;本地插件脚手架仍属于独立的工程创建问题。 +本提案只涉及开发者自有 SDK 工程的依赖。独立应用的仓库缓存、随应用捆绑 pnpm 的政策和显式的准备流程信任边界,均由[包管理器原生仓库缓存](../../implemented/architecture/2026-07-30-package-manager-native-repository-cache.md)负责。 + ## Launcher 遥测 ### Consent 与采集 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 754ae93d82..92ea0d2406 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -66,6 +66,7 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | | [`node-pty`](https://github.com/microsoft/node-pty) | MIT | | [`picomatch`](https://github.com/micromatch/picomatch) | MIT | +| [`pnpm`](https://github.com/pnpm/pnpm) | MIT | | [`react`](https://github.com/facebook/react) | MIT | | [`react-dom`](https://github.com/facebook/react) | MIT | | [`react-markdown`](https://github.com/remarkjs/react-markdown) | MIT | diff --git a/packages/ui/app-boot/tests/repository-cache.spec.ts b/packages/ui/app-boot/tests/repository-cache.spec.ts new file mode 100644 index 0000000000..33a223020f --- /dev/null +++ b/packages/ui/app-boot/tests/repository-cache.spec.ts @@ -0,0 +1,144 @@ +import { execFile } from 'node:child_process' +import { createHash } from 'node:crypto' +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { promisify } from 'node:util' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { BUNDLED_PNPM_VERSION, RepositoryCache, type RepositoryInstall } from '@cordisjs/plugin-loader/repository' + +const execFileAsync = promisify(execFile) +const roots: string[] = [] + +async function temporaryRoot(name: string): Promise { + const root = await mkdtemp(join(tmpdir(), `cordis-${name}-`)) + roots.push(root) + return root +} + +async function fakePackage(directory: string): Promise { + const target = join(directory, 'node_modules', 'repository') + await mkdir(target, { recursive: true }) + await writeFile(join(target, 'package.json'), '{"name":"fixture"}\n') +} + +afterEach(async () => { + vi.unstubAllEnvs() + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +describe('RepositoryCache', () => { + it('single-flights and permanently reuses an exact specifier', async () => { + const root = await temporaryRoot('repository-cache') + const calls: string[] = [] + const install: RepositoryInstall = async (directory) => { + calls.push(directory) + await fakePackage(directory) + } + const cache = new RepositoryCache(root, install) + const specifier = 'github:owner/repository#0123456789abcdef' + + const [first, concurrent] = await Promise.all([cache.resolve(specifier), cache.resolve(specifier)]) + expect(concurrent).toBe(first) + expect(calls).toHaveLength(1) + + const reopened = new RepositoryCache(root, async () => { throw new Error('cache miss') }) + expect(await reopened.resolve(specifier)).toBe(first) + expect(JSON.parse(await readFile(join(first, '..', '..', 'package.json'), 'utf8'))).toMatchObject({ + packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`, + dependencies: { repository: specifier }, + }) + + const second = await cache.resolve('github:owner/repository#fedcba9876543210') + expect(second).not.toBe(first) + expect(calls).toHaveLength(2) + }) + + it('accepts the valid winner when independent cache instances race', async () => { + const root = await temporaryRoot('repository-race') + const bothStarted = Promise.withResolvers() + let starts = 0 + const install: RepositoryInstall = async (directory) => { + await fakePackage(directory) + starts += 1 + if (starts === 2) bothStarted.resolve(undefined) + await bothStarted.promise + } + const specifier = 'github:owner/repository#race' + + const [first, second] = await Promise.all([ + new RepositoryCache(root, install).resolve(specifier), + new RepositoryCache(root, install).resolve(specifier), + ]) + + expect(second).toBe(first) + expect(starts).toBe(2) + expect(await readdir(root)).toHaveLength(1) + }) + + it('removes a failed staging tree and permits an exact retry', async () => { + const root = await temporaryRoot('repository-retry') + let attempts = 0 + const cache = new RepositoryCache(root, async (directory) => { + attempts += 1 + if (attempts === 1) throw new Error('install failed') + await fakePackage(directory) + }) + + await expect(cache.resolve('github:owner/repository#ref')).rejects.toThrow('failed to prepare repository') + expect(await readdir(root)).toEqual([]) + await expect(cache.resolve('github:owner/repository#ref')).resolves.toContain('node_modules') + expect(attempts).toBe(2) + }) + + it('rejects empty or padded specifiers before touching the cache', async () => { + const root = await temporaryRoot('repository-input') + const cache = new RepositoryCache(root, fakePackage) + expect(() => cache.resolve('')).toThrow('non-empty unpadded string') + expect(() => cache.resolve(' github:owner/repository#ref')).toThrow('non-empty unpadded string') + await expect(readdir(root)).resolves.toEqual([]) + }) + + it('fails loud on a corrupt published marker instead of reinstalling it', async () => { + const root = await temporaryRoot('repository-corrupt') + const specifier = 'github:owner/repository#corrupt' + const key = createHash('sha256').update(specifier).digest('hex') + const entry = join(root, key) + await mkdir(join(entry, 'node_modules', 'repository'), { recursive: true }) + await writeFile(join(entry, '.repository-cache.json'), '{}\n') + const cache = new RepositoryCache(root, async () => { throw new Error('must not reinstall') }) + + await expect(cache.resolve(specifier)).rejects.toThrow('repository cache marker is invalid') + }) + + it('runs a Git dependency prepare script through the bundled pnpm', { timeout: 60_000 }, async () => { + const root = await temporaryRoot('repository-pnpm') + const repository = join(root, 'source') + await mkdir(repository) + await writeFile(join(repository, 'package.json'), `${JSON.stringify({ + name: 'repository-fixture', + version: '1.0.0', + scripts: { prepare: 'node prepare.mjs' }, + })}\n`) + await writeFile(join(repository, 'prepare.mjs'), [ + "import { writeFile } from 'node:fs/promises'", + "await writeFile('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)", + '', + ].join('\n')) + await execFileAsync('git', ['init', '--quiet'], { cwd: repository }) + await execFileAsync('git', ['add', '.'], { cwd: repository }) + await execFileAsync('git', [ + '-c', 'user.name=Repository Fixture', + '-c', 'user.email=repository@example.invalid', + 'commit', '--quiet', '-m', 'fixture', + ], { cwd: repository }) + const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repository, encoding: 'utf8' }) + const specifier = `git+${pathToFileURL(repository).href}#${stdout.trim()}` + vi.stubEnv('REPOSITORY_TEST_VISIBLE', 'visible') + vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden') + + const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier) + await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent\n') + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cefd39d0ee..e7f8fd6148 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6548,6 +6548,9 @@ importers: node-addon-require-builtin: specifier: ^0.1.3 version: 0.1.3 + pnpm: + specifier: 11.7.0 + version: 11.7.0 vendor/logger-console: dependencies: @@ -10951,6 +10954,11 @@ packages: engines: {node: '>=18'} hasBin: true + pnpm@11.7.0: + resolution: {integrity: sha512-GcyFLBIMcSV2DyRD7mvgyltA+fUFmN4aCaHxd1A+AQ5Xwjx3ZG4B52HeWb+HT7IqM5jDOrlpH8E+uUa28PTWIA==} + engines: {node: '>=22.13'} + hasBin: true + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -16387,6 +16395,8 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pnpm@11.7.0: {} + points-on-curve@0.2.0: {} points-on-path@0.2.1: diff --git a/tsconfig.base.json b/tsconfig.base.json index 2d078202e7..94b60804e4 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -32,6 +32,7 @@ "cosmokit": ["./vendor/cosmokit/src"], "schemastery": ["./vendor/schemastery/src"], "@cordisjs/plugin-loader": ["./vendor/loader/src"], + "@cordisjs/plugin-loader/repository": ["./vendor/loader/src/repository.ts"], "@cordisjs/plugin-include": ["./vendor/include/src"], "@cordisjs/plugin-group": ["./vendor/group/src"], "@cordisjs/plugin-timer": ["./vendor/timer/src"], diff --git a/vendor/README.md b/vendor/README.md index 443c78e278..3872faa753 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,8 +39,9 @@ Keep this log exhaustive — every divergence from upstream must be listed. 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. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, undo changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. 9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/ui/app-boot/tests/hmr-config.spec.ts`. -10. **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. -11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` 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/config/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`. +10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/ui/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git prepare run through the bundled pnpm. +11. **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. +12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` 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/config/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`. ## Sync procedure diff --git a/vendor/loader/package.json b/vendor/loader/package.json index c7bbaf5176..e24e0657a6 100644 --- a/vendor/loader/package.json +++ b/vendor/loader/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./repository": { + "types": "./lib/types/repository.d.ts", + "default": "./lib/repository.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/repository.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -32,6 +37,7 @@ } }, "dependencies": { - "cosmokit": "^1.8.1" + "cosmokit": "^1.8.1", + "pnpm": "11.7.0" } } diff --git a/vendor/loader/src/repository.ts b/vendor/loader/src/repository.ts new file mode 100644 index 0000000000..94a0c5cf16 --- /dev/null +++ b/vendor/loader/src/repository.ts @@ -0,0 +1,191 @@ +/** + * Exact-specifier repository packages installed through the Loader's bundled + * pnpm. The caller owns source validation and the cache root; this module owns + * isolated installation, single-flight reuse, and atomic cache publication. + */ + +import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' +import { mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { dirname, join, resolve } from 'node:path' + +/** Exact pnpm release shipped with the Loader for repository installation. */ +export const BUNDLED_PNPM_VERSION = '11.7.0' + +const DEPENDENCY_NAME = 'repository' +const MARKER_NAME = '.repository-cache.json' +const MAX_ERROR_OUTPUT = 32 * 1024 +const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i + +/** Injectable isolated-install boundary used by {@link RepositoryCache}. */ +export type RepositoryInstall = (directory: string) => Promise + +interface CacheMarker { + specifier: string +} + +function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + return Object.fromEntries(Object.entries(environment).filter(([name]) => !SENSITIVE_ENV_PATTERN.test(name))) +} + +function appendOutput(current: string, chunk: Uint8Array): string { + const combined = current + Buffer.from(chunk).toString('utf8') + return combined.length <= MAX_ERROR_OUTPUT ? combined : combined.slice(-MAX_ERROR_OUTPUT) +} + +async function installWithBundledPnpm(directory: string): Promise { + const require = createRequire(import.meta.url) + const pnpmManifest = require.resolve('pnpm') + const pnpmBin = join(dirname(pnpmManifest), 'bin', 'pnpm.mjs') + let output = '' + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + const child = spawn(process.execPath, [ + pnpmBin, + 'install', + '--no-frozen-lockfile', + '--reporter=append-only', + ], { + cwd: directory, + env: scrubEnvironment(), + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }) + child.stdout.on('data', (chunk: Uint8Array) => { output = appendOutput(output, chunk) }) + child.stderr.on('data', (chunk: Uint8Array) => { output = appendOutput(output, chunk) }) + child.once('error', reject) + child.once('close', (code, signal) => { resolve({ code, signal }) }) + }) + if (result.signal !== null) { + throw new Error(`bundled pnpm install was killed by ${result.signal}${output ? `\n${output.trimEnd()}` : ''}`) + } + if (result.code !== 0) { + throw new Error(`bundled pnpm install exited with code ${String(result.code)}${output ? `\n${output.trimEnd()}` : ''}`) + } +} + +function cacheKey(specifier: string): string { + return createHash('sha256').update(specifier).digest('hex') +} + +async function readCached(directory: string, specifier: string): Promise { + let content: string + try { + content = await readFile(join(directory, MARKER_NAME), 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw error + } + let parsed: unknown + try { + parsed = JSON.parse(content) as unknown + } catch (error) { + throw new Error(`repository cache marker is invalid: ${join(directory, MARKER_NAME)}`, { cause: error }) + } + if (typeof parsed !== 'object' || parsed === null || typeof (parsed as Partial).specifier !== 'string') { + throw new Error(`repository cache marker is invalid: ${join(directory, MARKER_NAME)}`) + } + const marker = parsed as CacheMarker + if (marker.specifier !== specifier) { + throw new Error(`repository cache key collision for ${JSON.stringify(specifier)}`) + } + const packageDirectory = join(directory, 'node_modules', DEPENDENCY_NAME) + let packageStat + try { + packageStat = await stat(packageDirectory) + } catch (error) { + throw new Error(`repository cache entry is incomplete: ${directory}`, { cause: error }) + } + if (!packageStat.isDirectory()) throw new Error(`repository cache package is not a directory: ${packageDirectory}`) + return packageDirectory +} + +async function removeStaging(directory: string, cause: unknown): Promise { + try { + await rm(directory, { recursive: true, force: true }) + } catch (cleanupError) { + throw new AggregateError([cause, cleanupError], `failed to clean repository staging directory ${directory}`) + } + throw cause +} + +/** + * Persistent exact-specifier package cache backed by bundled pnpm. + * + * One isolated project contains one dependency named `repository`. A successful + * install is atomically renamed into its SHA-256 key, so failed installs never + * become cache hits. The exact specifier is immutable: callers change the + * specifier (normally its Git ref) to request another generation. + */ +export class RepositoryCache { + /** Absolute directory containing immutable repository cache entries. */ + readonly directory: string + + private readonly tasks = new Map>() + + /** + * @param directory - caller-owned persistent cache root. + * @param install - isolated package installation boundary; defaults to the bundled pnpm. + */ + constructor(directory: string, private readonly install: RepositoryInstall = installWithBundledPnpm) { + this.directory = resolve(directory) + } + + /** + * Resolve one package-manager-native dependency specifier to its installed package directory. + * @param specifier - exact immutable dependency specifier used as the permanent cache identity. + * @returns the installed `repository` dependency directory. + * @throws when the specifier is empty/padded, installation fails, or a published cache entry is corrupt. + */ + resolve(specifier: string): Promise { + if (!specifier || specifier.trim() !== specifier) { + throw new TypeError('repository specifier must be a non-empty unpadded string') + } + const existing = this.tasks.get(specifier) + if (existing) return existing + const task = this.resolveUncached(specifier).finally(() => { + if (this.tasks.get(specifier) === task) this.tasks.delete(specifier) + }) + this.tasks.set(specifier, task) + return task + } + + private async resolveUncached(specifier: string): Promise { + const finalDirectory = join(this.directory, cacheKey(specifier)) + const cached = await readCached(finalDirectory, specifier) + if (cached) return cached + + await mkdir(this.directory, { recursive: true }) + const staging = await mkdtemp(join(this.directory, '.repository-')) + try { + await writeFile(join(staging, 'package.json'), `${JSON.stringify({ + name: 'cordis-repository-cache-entry', + private: true, + version: '0.0.0', + packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`, + dependencies: { [DEPENDENCY_NAME]: specifier }, + }, undefined, 2)}\n`) + await writeFile(join(staging, 'pnpm-workspace.yaml'), [ + 'packages: []', + 'dangerouslyAllowAllBuilds: true', + '', + ].join('\n')) + await this.install(staging) + const packageDirectory = join(staging, 'node_modules', DEPENDENCY_NAME) + const packageStat = await stat(packageDirectory) + if (!packageStat.isDirectory()) throw new Error(`installed repository is not a directory: ${packageDirectory}`) + await writeFile(join(staging, MARKER_NAME), `${JSON.stringify({ specifier })}\n`) + try { + await rename(staging, finalDirectory) + } catch (error) { + const winner = await readCached(finalDirectory, specifier) + if (!winner) throw error + await rm(staging, { recursive: true, force: true }) + return winner + } + } catch (error) { + return removeStaging(staging, new Error(`failed to prepare repository ${JSON.stringify(specifier)}`, { cause: error })) + } + return (await readCached(finalDirectory, specifier))! + } +} diff --git a/vendor/loader/tsdown.config.ts b/vendor/loader/tsdown.config.ts new file mode 100644 index 0000000000..75e627cdd2 --- /dev/null +++ b/vendor/loader/tsdown.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'tsdown' + +/** Keep the browser-reachable Loader entry separate from the Node-only repository cache. */ +const shared = { + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + outputOptions: { codeSplitting: false }, + dts: false, + clean: false, +} as const + +export default defineConfig([ + { ...shared, entry: ['lib/types/index.js'] }, + { ...shared, entry: ['lib/types/repository.js'] }, +]) From fa7051a9d1b6a35fc037b464d71742ed0e391329 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 04:15:07 +0800 Subject: [PATCH 15/23] feat: add static repository plugin format --- ...-static-repository-plugin-format.i18n.yaml | 6 + ...6-07-30-static-repository-plugin-format.md | 49 ++++ ...7-30-static-repository-plugin-format.zh.md | 49 ++++ docs/config-catalog.md | 5 + docs/module-graph.md | 5 + .../tests/fixtures/cli.cordis.yml | 10 + .../skills/0/repository-fixture/SKILL.md | 6 + .../fixtures/repository-plugin/dsh-plugin.mjs | 9 + .../headless-agent/tests/keyless-smoke.e2e.ts | 11 + examples/package.json | 1 + packages/README.i18n.yaml | 4 +- packages/README.md | 2 +- packages/README.zh.md | 2 +- packages/cordis/README.i18n.yaml | 4 +- packages/cordis/README.md | 5 +- packages/cordis/README.zh.md | 5 +- .../cordis/repository-plugin/README.i18n.yaml | 6 + packages/cordis/repository-plugin/README.md | 85 ++++++ .../cordis/repository-plugin/README.zh.md | 85 ++++++ .../cordis/repository-plugin/package.json | 53 ++++ packages/cordis/repository-plugin/src/bin.ts | 12 + .../cordis/repository-plugin/src/format.ts | 168 ++++++++++++ .../cordis/repository-plugin/src/index.ts | 97 +++++++ .../cordis/repository-plugin/src/invariant.ts | 30 +++ packages/cordis/repository-plugin/src/mcp.ts | 145 +++++++++++ .../tests/mcp-format.spec.ts | 109 ++++++++ .../tests/repository-plugin.spec.ts | 243 ++++++++++++++++++ .../cordis/repository-plugin/tsconfig.json | 30 +++ .../cordis/repository-plugin/tsdown.config.ts | 17 ++ packages/skill/skill-local/README.i18n.yaml | 4 +- packages/skill/skill-local/README.md | 4 +- packages/skill/skill-local/README.zh.md | 4 +- packages/skill/skill-local/src/index.ts | 37 ++- pnpm-lock.yaml | 34 +++ tsconfig.host.json | 1 + 35 files changed, 1310 insertions(+), 27 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md create mode 100644 .agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md create mode 100644 examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin-assets/skills/0/repository-fixture/SKILL.md create mode 100644 examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs create mode 100644 packages/cordis/repository-plugin/README.i18n.yaml create mode 100644 packages/cordis/repository-plugin/README.md create mode 100644 packages/cordis/repository-plugin/README.zh.md create mode 100644 packages/cordis/repository-plugin/package.json create mode 100644 packages/cordis/repository-plugin/src/bin.ts create mode 100644 packages/cordis/repository-plugin/src/format.ts create mode 100644 packages/cordis/repository-plugin/src/index.ts create mode 100644 packages/cordis/repository-plugin/src/invariant.ts create mode 100644 packages/cordis/repository-plugin/src/mcp.ts create mode 100644 packages/cordis/repository-plugin/tests/mcp-format.spec.ts create mode 100644 packages/cordis/repository-plugin/tests/repository-plugin.spec.ts create mode 100644 packages/cordis/repository-plugin/tsconfig.json create mode 100644 packages/cordis/repository-plugin/tsdown.config.ts diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml new file mode 100644 index 0000000000..0190e19f8e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.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-30-static-repository-plugin-format.md +2026-07-30-static-repository-plugin-format.md: c4739a6843db515d4cd67441e74bbf05228c612a +2026-07-30-static-repository-plugin-format.zh.md: 0b0a2af132137a820ba941869a612b7f28754fcb diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md new file mode 100644 index 0000000000..c4739a6843 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md @@ -0,0 +1,49 @@ +# Agent Note: Static repository Plugin format + +Status: implemented + +English | [中文](2026-07-30-static-repository-plugin-format.zh.md) + +## Problem + +A repository that already contains reusable skills or an MCP server declaration should be usable by standalone Harness applications without becoming a Harness SDK project or rewriting its existing layout. Popular repositories must be able to add one `.dsh-plugin` directory while keeping their current skills and `.mcp.json` elsewhere in the tree. At the same time, treating an arbitrary repository entry point as a Cordis Plugin would make every repository a new unrestricted runtime extension surface and would bypass the existing skill and MCP lifecycle owners. + +The [package-manager-native repository cache](2026-07-30-package-manager-native-repository-cache.md) prepares an exact package source but intentionally knows nothing about DSH formats. This layer therefore needs a package-manager-compatible authoring format, a deterministic prepared artifact, and a Cordis composition that stays transactional under Loader disposal and replacement. + +## Decision + +`@deepseek-ai/dsh-repository-plugin` owns a restricted `.dsh-plugin` package format with two contribution kinds only: skill roots and one common `.mcp.json`. Its package metadata uses `package.json#dsh.skills` for relative skill-root paths and `package.json#dsh.mcpServers` for the relative MCP document path. At least one is required. Each path may leave `.dsh-plugin` to reuse repository content but must remain beneath the directory containing that `.dsh-plugin`; a nested selectable Plugin therefore owns the adjacent subtree above its package without gaining access to unrelated host paths. + +The `.dsh-plugin` package declares `dsh-plugin-prepare` as its ordinary package-manager `prepare` script. The helper validates metadata and source types, strictly parses `.mcp.json`, copies static assets into `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. The `.mjs` extension avoids imposing `type: module` on repository-authored package metadata. The generated module is a fixed import-free template containing only a normalized manifest, `inject = ['loader']`, and delegation to the `dsh-repository-plugin` Loader builtin. Preparation never discovers, transpiles, bundles, or preserves a custom repository entry point. + +Loading the DSH package registers that builtin as an effect. A generated wrapper mounts the builtin as its child with `import.meta.url`, so all contributions belong to the wrapper fiber and disappear on Loader removal or rollback. The builtin revalidates the prepared manifest and path containment before reading assets. It composes the existing implementations rather than registering skills or MCP tools itself. + +Each prepared skill set mounts `dsh-skill-local` with a unique `repository:` provider name, only the copied custom roots, and watching disabled. `dsh-skill-local` therefore gains two general configuration fields: `providerName` and `includeDefaultRoots`. Their defaults preserve its existing single local provider; repository instances set a distinct name and exclude project/user roots so multiple instances neither collide nor duplicate host-local discovery. + +Each `.mcp.json` server becomes one existing `dsh-mcp-client` child. The adapter accepts the common root `{ "mcpServers": ... }`; stdio definitions allow only optional `type: "stdio"`, `command`, `args`, and `env`, while HTTP definitions allow only `type: "http"`, `url`, and `headers`. Exact `${NAME}` process-environment references expand at runtime, after cache preparation; missing names fail Plugin load. HTTP maps to the client's Streamable HTTP transport, and stdio uses the prepared package directory as `cwd`. The existing client alone owns connection attempts, failure logging, remote tool synchronization, tool calls, and disconnects. Consequently an MCP connection failure keeps its established successful-plugin/no-tools behavior and is not reclassified as a repository preparation or Loader failure. + +Unknown MCP fields reject. This intentionally excludes OAuth, `auth` objects, `CLAUDE_PLUGIN_ROOT`, and a broader Claude compatibility contract. Hooks, commands, agents, apps, arbitrary Cordis code, marketplaces, and discovery are also unsupported. Repository subdirectory selection and GitHub configuration belong to the later app/cache integration, not this format package. + +## Alternatives considered + +**Load a repository's own Cordis entry point.** Rejected because it makes the advertised static format an unrestricted code-loading API, requires repository authors to depend on Harness internals, and duplicates the ordinary SDK/plugin-dependency path. + +**Teach generated wrappers to implement skills and MCP directly.** Rejected because copied runtime code would drift from `dsh-skill-local` and `dsh-mcp-client`, especially their provider invalidation, tool synchronization, failure, and teardown contracts. + +**Import Harness packages from each generated wrapper.** Rejected because repository packages should not resolve or version the application's internal dependency graph. A Loader builtin supplies one app-owned implementation and keeps generated wrappers import-free. + +**Watch prepared repository assets.** Rejected because an exact repository cache generation is immutable. Ref, subdirectory, or configuration changes select a new generation; a second watcher would create an unowned refresh identity. + +**Treat MCP connect failures as Loader update failures.** Rejected because the existing MCP client deliberately contains connect failures and exposes no tools. Changing that semantic only for repository sources would create two failure contracts for the same server configuration. + +## Consequences + +- Existing skill/MCP repositories can add a small `.dsh-plugin/package.json` without relocating their assets or adopting an SDK project. +- Prepared output is deterministic static glue, while the configured repository and its dependency lifecycle remain trusted executable package-manager input rather than a sandbox. +- Multiple repository Plugins coexist through provider names and ordinary MCP server-name uniqueness; duplicate names fail through their existing registries and participate in Loader rollback. +- Cached source edits do not appear live. Another exact source/ref/path/config selection is required. +- Adding another contribution kind requires an explicit format and DSH-owned runtime consumer; it cannot arrive as repository JavaScript by accident. + +## Testing + +Focused tests prepare skills and MCP metadata, prove the emitted wrapper contains no imports, reject Work IQ-style OAuth fields, map Expo-style HTTP and DataJunction-style stdio plus environment values, and exercise missing variables. A real Loader test mounts a generated wrapper through the registered builtin, reads its skill through `ctx.skills`, removes the Loader entry, and observes provider cleanup. The keyless headless example loads a checked-in prepared wrapper through its real `cordis.yml` and snapshots the repository skill's logged model catalog row. diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md new file mode 100644 index 0000000000..0b0a2af132 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md @@ -0,0 +1,49 @@ +# Agent Note:静态 repository Plugin 格式 + +状态:已实现 + +[English](2026-07-30-static-repository-plugin-format.md) | 中文 + +## 问题 + +一个已经包含可复用 skills 或 MCP server 声明的仓库,应当能被独立 Harness 应用使用,而不必先变成 Harness SDK 项目,也不应被迫改写现有布局。常见仓库只需新增一个 `.dsh-plugin` 目录,同时仍可把原有 skills 与 `.mcp.json` 放在仓库其他位置。与此同时,如果把任意仓库入口都当作 Cordis Plugin,就会让每个仓库成为新的无限制运行时扩展表面,并绕过现有的 skill 与 MCP 生命周期所有者。 + +[Package-manager-native repository cache](2026-07-30-package-manager-native-repository-cache.md) 会准备一个精确 package source,但有意不了解任何 DSH 格式。因此本层需要一种兼容 package manager 的创作格式、确定性的已准备产物,以及在 Loader dispose 和替换期间仍保持事务性的 Cordis 组合。 + +## 决策 + +`@deepseek-ai/dsh-repository-plugin` 负责一个受限的 `.dsh-plugin` package 格式,且只允许两类贡献:skill 根和一个通用 `.mcp.json`。Package metadata 使用 `package.json#dsh.skills` 声明相对 skill 根路径,使用 `package.json#dsh.mcpServers` 声明相对 MCP 文档路径;两者至少需要一个。路径可以离开 `.dsh-plugin` 以复用仓库内容,但必须留在包含该 `.dsh-plugin` 的目录之下;因此,一个嵌套且可选择的 Plugin 可以拥有其 package 上方相邻的子树,却不能访问无关宿主路径。 + +`.dsh-plugin` package 把 `dsh-plugin-prepare` 声明为普通 package-manager `prepare` 脚本。Helper 会校验 metadata 与源码类型,严格解析 `.mcp.json`,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。`.mjs` 扩展名避免强迫仓库作者在 package metadata 中设置 `type: module`。生成模块来自固定、无 import 的模板,只包含规范化 manifest、`inject = ['loader']`,以及对 `dsh-repository-plugin` Loader builtin 的委托。准备阶段永远不会发现、转译、打包或保留自定义仓库入口。 + +加载 DSH package 会以 effect 方式注册该 builtin。生成的包装模块使用 `import.meta.url` 把 builtin 挂载为自己的子级,因此所有贡献都归属于包装 fiber,并在 Loader 移除或回滚时消失。Builtin 会在读取资源前重新校验已准备 manifest 与路径包含关系。它只组合现有实现,而不自行注册 skills 或 MCP 工具。 + +每份已准备 skill 集合都会挂载 `dsh-skill-local`,使用唯一的 `repository:` 提供方名称、仅包含复制后的自定义根,并禁用监视。因此 `dsh-skill-local` 新增两个通用配置字段:`providerName` 和 `includeDefaultRoots`。默认值保持原有单一本地提供方行为;repository 实例设置不同名称并排除项目/用户根,使多个实例既不冲突,也不会重复宿主本地发现。 + +`.mcp.json` 中的每个 server 都变成一个现有 `dsh-mcp-client` 子级。适配层接受通用根对象 `{ "mcpServers": ... }`;stdio 定义只允许可选的 `type: "stdio"`、`command`、`args` 与 `env`,HTTP 定义只允许 `type: "http"`、`url` 与 `headers`。严格的 `${NAME}` 进程环境变量引用在运行时、cache 准备之后展开;缺失变量会使 Plugin 加载失败。HTTP 映射到 client 的 Streamable HTTP transport,stdio 使用已准备 package 目录作为 `cwd`。只有现有 client 负责连接尝试、失败日志、远端工具同步、工具调用和断开。因此 MCP 连接失败会继续沿用“Plugin 成功但不注册工具”的既有行为,不会被重新分类为 repository 准备或 Loader 失败。 + +未知 MCP 字段会被拒绝。这里有意排除 OAuth、`auth` 对象、`CLAUDE_PLUGIN_ROOT` 和更广泛的 Claude 兼容契约。Hooks、commands、agents、apps、任意 Cordis 代码、marketplace 和发现同样不受支持。Repository 子目录选择与 GitHub 配置属于后续 app/cache 集成,而不是本格式 package。 + +## 考虑过的替代方案 + +**加载仓库自己的 Cordis 入口。** 拒绝,因为这会把宣传为静态的格式变成无限制代码加载 API,要求仓库作者依赖 Harness 内部实现,并重复普通 SDK/Plugin dependency 路径。 + +**让生成包装模块直接实现 skills 和 MCP。** 拒绝,因为复制的运行时代码会与 `dsh-skill-local` 和 `dsh-mcp-client` 漂移,尤其是提供方失效、工具同步、失败和 teardown 契约。 + +**让每个生成包装模块 import Harness package。** 拒绝,因为 repository package 不应解析或锁定应用的内部依赖图。Loader builtin 提供一份由 app 所有的实现,并让生成包装模块保持无 import。 + +**监视已准备 repository 资源。** 拒绝,因为一个精确 repository cache generation 是不可变的。Ref、子目录或配置变化会选择新 generation;第二套 watcher 会创造一套没有所有者的刷新身份。 + +**把 MCP 连接失败当作 Loader 更新失败。** 拒绝,因为现有 MCP client 有意收束连接失败并不暴露工具。只对 repository source 改变该语义,会让同一 server 配置拥有两套失败契约。 + +## 后果 + +- 现有 skill/MCP 仓库可以新增一个很小的 `.dsh-plugin/package.json`,无需移动资源或采用 SDK 项目。 +- 已准备输出是确定性的静态胶水;已配置仓库及其依赖生命周期仍是受信任的可执行 package-manager 输入,而非 sandbox。 +- 多个 repository Plugin 通过提供方名称和普通 MCP server-name 唯一性共存;重复名称经现有 registry 失败,并参与 Loader 回滚。 +- Cache 内的源码编辑不会实时出现;必须选择另一个精确 source/ref/path/config。 +- 新增贡献类型必须提供显式格式和 DSH 自有运行时消费方;它不能意外以 repository JavaScript 形式进入。 + +## 测试 + +聚焦测试会准备 skills 与 MCP metadata,证明生成包装模块不含 import,拒绝 Work IQ 风格的 OAuth 字段,映射 Expo 风格 HTTP 与 DataJunction 风格 stdio 及环境变量,并覆盖缺失变量。真实 Loader 测试通过已注册 builtin 挂载生成包装模块,经 `ctx.skills` 读取其 skill,移除 Loader 条目并观察提供方清理。Keyless headless 示例通过真实 `cordis.yml` 加载一份签入的已准备包装模块,并快照 repository skill 写入日志的模型目录行。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d339d216e1..457f1be7b8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1312,6 +1312,10 @@ Requires: `skills` ```ts config-catalog /** Local filesystem skill provider configuration. */ export interface Config { + /** Unique provider name. Defaults to `local`. */ + providerName?: string + /** Whether project and user roots are included around custom roots. */ + includeDefaultRoots?: boolean /** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */ dshHome?: string /** Shared agent config root. Defaults to `$DSH_AGENTS_HOME` or `~/.agents`. */ @@ -2318,6 +2322,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts)) +- `@deepseek-ai/dsh-repository-plugin` — requires `loader` ([`packages/cordis/repository-plugin/src/index.ts`](../packages/cordis/repository-plugin/src/index.ts)) - `@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)) diff --git a/docs/module-graph.md b/docs/module-graph.md index f0f58a18c1..9c3afcec85 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -94,6 +94,7 @@ flowchart TD pkg_plan_mode["plan-mode"] end subgraph group_cordis["packages/cordis"] + pkg_repository_plugin["repository-plugin"] pkg_tool_cordis["tool-cordis"] end subgraph group_hooks["packages/hooks"] @@ -904,6 +905,9 @@ flowchart TD pkg_tool_subagent --> pkg_subagent pkg_tool_subagent --> pkg_tasks pkg_tool_subagent --> pkg_tools + pkg_repository_plugin --> pkg_invariants + pkg_repository_plugin --> pkg_mcp_client + pkg_repository_plugin --> pkg_skill_local pkg_hooks_claude --> pkg_agent pkg_hooks_claude --> pkg_hook_protocol pkg_hooks_claude --> pkg_invariants @@ -1208,6 +1212,7 @@ flowchart TD | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`repository-plugin`](../packages/cordis/repository-plugin) | `cordis` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`skill-local`](../packages/skill/skill-local) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`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) | diff --git a/examples/headless-agent/tests/fixtures/cli.cordis.yml b/examples/headless-agent/tests/fixtures/cli.cordis.yml index 91941c108a..5e1a296de5 100644 --- a/examples/headless-agent/tests/fixtures/cli.cordis.yml +++ b/examples/headless-agent/tests/fixtures/cli.cordis.yml @@ -1,6 +1,12 @@ - id: cli-mock-llm name: './cli-mock-llm.ts' +- id: repository-plugin-runtime + name: '@deepseek-ai/dsh-repository-plugin' + +- id: repository-plugin-fixture + name: './repository-plugin/dsh-plugin.mjs' + - id: base name: '@cordisjs/plugin-include' config: @@ -16,4 +22,8 @@ model: cli-mock persistenceRoot: './.sessions' workspaceContext: false + dshHome: './.dsh-home' + skills: + local: + agentsHome: './.agents-home' persona: 'Keyless headless-agent smoke.' diff --git a/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin-assets/skills/0/repository-fixture/SKILL.md b/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin-assets/skills/0/repository-fixture/SKILL.md new file mode 100644 index 0000000000..e24104e79f --- /dev/null +++ b/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin-assets/skills/0/repository-fixture/SKILL.md @@ -0,0 +1,6 @@ +--- +name: repository-fixture +description: Repository fixture skill. +--- + +Static instructions from a prepared repository plugin. diff --git a/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs b/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs new file mode 100644 index 0000000000..31225c0afc --- /dev/null +++ b/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs @@ -0,0 +1,9 @@ +// Generated by dsh-plugin-prepare. Do not edit. +const manifest = { "name": "headless-repository-fixture", "skills": ["dsh-plugin-assets/skills/0"] } +export const name = 'headless-repository-fixture' +export const inject = ['loader'] +export async function apply(ctx) { + const runtime = ctx.loader.builtins['dsh-repository-plugin'] + if (runtime === undefined) throw new Error('missing Cordis builtin dsh-repository-plugin') + await ctx.plugin(runtime, { baseUrl: import.meta.url, manifest }) +} diff --git a/examples/headless-agent/tests/keyless-smoke.e2e.ts b/examples/headless-agent/tests/keyless-smoke.e2e.ts index 4cd06aed78..d5e1cce826 100644 --- a/examples/headless-agent/tests/keyless-smoke.e2e.ts +++ b/examples/headless-agent/tests/keyless-smoke.e2e.ts @@ -36,6 +36,17 @@ describe('headless-agent keyless smoke', () => { const result = lines.at(-1) expect(stderr).toBe('') expect(events.some(event => event.type === 'tool/call' && event.data.name === 'bash')).toBe(true) + const catalogMessage = events.find(event => event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === 'dsh-tool-skill') + const catalog = catalogMessage?.type === 'user/message' + ? catalogMessage.data.content.filter(block => block.type === 'text').map(block => block.text).join('\n') + : '' + expect(catalog.split('\n').find(line => line.includes('repository-fixture'))).toMatchInlineSnapshot( + ` + "- \`repository-fixture\`: Repository fixture skill." + `, + ) const toolResult = events.find(event => event.type === 'tool/result') expect(JSON.stringify(toolResult)).toContain('CLI_TOOL_ROUND_TRIP') expect(result).toMatchObject({ diff --git a/examples/package.json b/examples/package.json index b7e7b5d736..97e7918361 100644 --- a/examples/package.json +++ b/examples/package.json @@ -43,6 +43,7 @@ "@deepseek-ai/dsh-pty": "workspace:*", "@deepseek-ai/dsh-pty-local": "workspace:*", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", + "@deepseek-ai/dsh-repository-plugin": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:*", diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 369277ba3f..5491ce5432 100644 --- a/packages/README.i18n.yaml +++ b/packages/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/README.md -README.md: 0c729f781151fcc0bda81899e51227e71c7b8d2b -README.zh.md: 660a24eeea5f1a36841654626d94412371a2f462 +README.md: c8984bfa652a0ad7e12bc1f2001618df452bc863 +README.zh.md: 2a59a63d22cdb2e5c0de53cd1dcfce1296882c01 diff --git a/packages/README.md b/packages/README.md index 0c729f7811..c8984bfa65 100644 --- a/packages/README.md +++ b/packages/README.md @@ -33,7 +33,7 @@ Packages live at `packages///`; groups are containers, while names r | [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface | | [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface | | [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface | -| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface | +| [`cordis/`](cordis/README.md) | Cordis runtime integration: self-inspection/model-written temporary Plugins and restricted repository Plugin loading | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence seam + JSONL/SQLite backends | Product — stable surface | | [`session-projection/`](session-projection/README.md) | Projection seam: domain fold units serve whole values | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index 660a24eeea..2a59a63d22 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -33,7 +33,7 @@ | [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定表面 | | [`timeout/`](timeout/README.md) | 工具调用超时策略:`tools/execute` 截止时间强制执行器 | 产品:稳定表面 | | [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 | 产品:稳定表面 | -| [`cordis/`](cordis/README.md) | 自指运行时工具集:检查实时运行时的插件与服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | 产品:稳定表面 | +| [`cordis/`](cordis/README.md) | Cordis 运行时集成:自检/模型编写的临时 Plugin,以及受限 repository Plugin 加载 | 产品:稳定表面 | | [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude Code/Codex 协议格式库 | 产品:稳定表面 | | [`session-persistence/`](session-persistence/README.md) | 持久化 seam + JSONL/SQLite 后端 | 产品:稳定表面 | | [`session-projection/`](session-projection/README.md) | 投影 seam:领域折叠单元供给全量值 | 产品:稳定表面 | diff --git a/packages/cordis/README.i18n.yaml b/packages/cordis/README.i18n.yaml index aaa96435d3..29f9e303de 100644 --- a/packages/cordis/README.i18n.yaml +++ b/packages/cordis/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/cordis/README.md -README.md: a47b9ba20789bb6b9a36b1af9b3942b90e61b365 -README.zh.md: 3ee1ddb1db28352cd05b3e79e88228035bc39ac4 +README.md: 485a6ce7858a77507c07b76138127faa411b354b +README.zh.md: 38bfcd9fcb50f608e83bafa34def5561a847c066 diff --git a/packages/cordis/README.md b/packages/cordis/README.md index a47b9ba207..485a6ce785 100644 --- a/packages/cordis/README.md +++ b/packages/cordis/README.md @@ -1,9 +1,10 @@ -# packages/cordis — the self-referential runtime toolset +# packages/cordis — Cordis runtime integration English | [中文](README.zh.md) -Model-facing tools over the live cordis runtime the agent itself runs inside: inspect the loaded plugins and service surface, mount model-written plugins, and dispose them again. Design home: [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). +Plugins that integrate Harness-owned formats with the Cordis runtime: the self-referential model toolset and the restricted repository Plugin runtime. | Package | Role | ctx key | |---|---|---| | [`tool-cordis/`](tool-cordis/README.md) | The `cordis_inspect` / `cordis_mount` / `cordis_unmount` tools: read the current-process runtime and manage in-memory temporary Plugins under one owned group fiber | registers on `ctx.tools` | +| [`repository-plugin/`](repository-plugin/README.md) | Prepare and mount static repository skills plus common `.mcp.json` servers through DSH-owned child Plugins | registers a Loader builtin | diff --git a/packages/cordis/README.zh.md b/packages/cordis/README.zh.md index 3ee1ddb1db..38bfcd9fcb 100644 --- a/packages/cordis/README.zh.md +++ b/packages/cordis/README.zh.md @@ -1,9 +1,10 @@ -# packages/cordis:自指运行时工具集 +# packages/cordis:Cordis 运行时集成 [English](README.md) | 中文 -这些面向模型的工具作用于 agent(智能体)自身所在的实时 Cordis 运行时,可检查已加载的插件和服务接口、挂载模型编写的插件,并将其 dispose(资源释放)。设计说明见[工具集 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 +这些 Plugin 把 Harness 自有格式集成到 Cordis 运行时:包括自指的模型工具集,以及受限的 repository Plugin 运行时。 | 包(package) | 角色 | ctx 键 | |---|---|---| | [`tool-cordis/`](tool-cordis/README.md) | `cordis_inspect`/`cordis_mount`/`cordis_unmount` 工具:读取当前进程运行时,并在一个自有分组 fiber 下管理内存中的临时插件 | 注册到 `ctx.tools` | +| [`repository-plugin/`](repository-plugin/README.md) | 通过 DSH 自有子 Plugin 准备并挂载静态 repository skills 与通用 `.mcp.json` server | 注册一个 Loader builtin | diff --git a/packages/cordis/repository-plugin/README.i18n.yaml b/packages/cordis/repository-plugin/README.i18n.yaml new file mode 100644 index 0000000000..806ee1c3af --- /dev/null +++ b/packages/cordis/repository-plugin/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/cordis/repository-plugin/README.md +README.md: dab6287304f083e5c0ae128d5a3cb861332c076a +README.zh.md: 790e601ad022ffa20c7d02a88353e972bf8bffe2 diff --git a/packages/cordis/repository-plugin/README.md b/packages/cordis/repository-plugin/README.md new file mode 100644 index 0000000000..dab6287304 --- /dev/null +++ b/packages/cordis/repository-plugin/README.md @@ -0,0 +1,85 @@ +# @deepseek-ai/dsh-repository-plugin + +English | [中文](README.zh.md) + +Restricted repository Plugin format for DeepSeek Harness. A repository author declares static skill roots and an optional common `.mcp.json` in `.dsh-plugin/package.json`; the prepare helper copies those assets and emits a fixed import-free Cordis wrapper. The runtime wrapper can only delegate to this DSH-owned package, which composes [`dsh-skill-local`](../../skill/skill-local/README.md) and [`dsh-mcp-client`](../../mcp/mcp-client/README.md). Design rationale: [static repository Plugin format Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md). + +## Authoring format + +Place an ordinary package in the repository's `.dsh-plugin` directory: + +```json +{ + "name": "humanize-dsh-plugin", + "version": "0.0.0", + "private": true, + "scripts": { + "prepare": "dsh-plugin-prepare" + }, + "devDependencies": { + "@deepseek-ai/dsh-repository-plugin": "^0.0.1" + }, + "dsh": { + "skills": ["../skills"], + "mcpServers": "../.mcp.json" + } +} +``` + +`dsh.skills` is an optional array of local skill roots. `dsh.mcpServers` is an optional path to one `.mcp.json`; at least one field is required. Paths are relative to `.dsh-plugin`, must stay under its parent source directory, and may therefore refer to existing repository assets such as `../skills`. A repository containing several Plugins gives each one its own `.dsh-plugin` package under a different selectable subdirectory. + +## Preparation + +`dsh-plugin-prepare` validates `package.json#dsh`, verifies skill-root types, parses the MCP file, copies assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. The wrapper contains only the normalized static manifest and fixed code that looks up the `dsh-repository-plugin` Loader builtin. It neither discovers nor compiles repository JavaScript, and the runtime never imports another repository entry point. + +The containing package manager still runs the configured repository package's lifecycle scripts. This restriction defines the supported DSH contribution surface; it is not a security boundary for a repository that the user chose to install as executable package-manager source. + +## Runtime composition + +Loading this package registers one effect-scoped Loader builtin. Each generated wrapper delegates to that builtin with its own module URL and prepared manifest. Repository skill roots mount as a uniquely named `dsh-skill-local` provider with default project/user roots excluded and watching disabled; cached package generations are immutable. Wrapper disposal removes the provider and all composed MCP clients through normal Cordis child-fiber teardown. + +## Common MCP format + +The `.mcp.json` root is `{ "mcpServers": { ... } }`. A stdio entry accepts only `type: "stdio"` (optional), `command`, `args`, and `env`; an HTTP entry accepts only `type: "http"`, `url`, and `headers`. String values support exact `${NAME}` process-environment expansion at Plugin load, and a missing name fails that load. HTTP URLs become the existing MCP client's `streamable-http` transport; stdio entries use the prepared package directory as `cwd`. + +Unknown fields reject, including OAuth and `auth` objects. There is no `CLAUDE_PLUGIN_ROOT` expansion or compatibility layer. After translation, the existing `dsh-mcp-client` exclusively owns transport creation, connection diagnostics, tool synchronization, calls, and disconnect lifecycle; a network or child-process connection failure retains that client's established log-and-no-tools behavior. + +## Export shape + +Namespace Plugin: named exports `name` / `inject` / `apply`, preparation constants, and `prepareDshPlugin`; no default export. The package also exposes the `dsh-plugin-prepare` executable and an invariant companion. + +## Model Experience + +### Repository skills + +#### What the model sees + +Indirectly through `dsh-tool-skill`: prepared, model-invocable skills join its logged catalog and selected instruction-body surface under their declared names and descriptions. The exact consumer schema is in the generated [`skill` tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-tool-skill). + +#### Token effect + +Conditional and data-dependent: each visible repository skill adds one capped catalog row; loading one adds its full current instruction body and resource-base guidance to retained tool history. + +#### KV Cache effect + +A stable prepared Plugin set is prefix-stable. Adding, removing, or replacing a repository Plugin can append the consumer's replacement catalog and affect later request prefixes. + +### Repository MCP tools + +#### What the model sees + +Indirectly through `dsh-mcp-client`: every connected server contributes its server-qualified tool schemas, and calls retain that client's canonical MCP results and rendering. + +#### Token effect + +Conditional on successful connection and the remote tool list; schemas recur on requests in the active tool view, while calls and results remain in history until compaction. + +#### KV Cache effect + +Stable connected tool lists are prefix-stable. Plugin lifecycle or MCP tool-list changes can change later tool-schema prefixes from the first affected definition. + +## Known Limitations and Deferred Work + +- **Skills and MCP only** — commands, hooks, agents, apps, arbitrary Cordis code, marketplaces, and compatibility shims are intentionally outside this format. +- **No MCP authentication protocol** — static headers may use environment expansion, but OAuth-bearing definitions reject and private-server login flows are not implemented here. +- **Generated assets are immutable runtime input** — repository cache generations are not watched; source, ref, path, or configuration must select another prepared generation. diff --git a/packages/cordis/repository-plugin/README.zh.md b/packages/cordis/repository-plugin/README.zh.md new file mode 100644 index 0000000000..790e601ad0 --- /dev/null +++ b/packages/cordis/repository-plugin/README.zh.md @@ -0,0 +1,85 @@ +# @deepseek-ai/dsh-repository-plugin + +[English](README.md) | 中文 + +这是 DeepSeek Harness 的受限 repository Plugin 格式。仓库作者在 `.dsh-plugin/package.json` 中声明静态 skill 根和可选的通用 `.mcp.json`;prepare helper 会复制这些资源并生成固定、无 import 的 Cordis 包装模块。运行时包装模块只能委托给这个由 DSH 自有的包,再由它组合 [`dsh-skill-local`](../../skill/skill-local/README.md) 与 [`dsh-mcp-client`](../../mcp/mcp-client/README.md)。设计依据见[静态 repository Plugin 格式 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md)。 + +## 创作格式 + +在仓库的 `.dsh-plugin` 目录中放置一个普通 package: + +```json +{ + "name": "humanize-dsh-plugin", + "version": "0.0.0", + "private": true, + "scripts": { + "prepare": "dsh-plugin-prepare" + }, + "devDependencies": { + "@deepseek-ai/dsh-repository-plugin": "^0.0.1" + }, + "dsh": { + "skills": ["../skills"], + "mcpServers": "../.mcp.json" + } +} +``` + +`dsh.skills` 是可选的本地 skill 根数组。`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径;两者至少声明一个。路径相对于 `.dsh-plugin`,必须留在其父级源码目录下,因此可以引用 `../skills` 等仓库现有资源。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` package。 + +## 准备阶段 + +`dsh-plugin-prepare` 校验 `package.json#dsh`、确认 skill 根类型、解析 MCP 文件、把资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。包装模块只包含规范化后的静态 manifest(元数据清单),以及查找 `dsh-repository-plugin` Loader builtin 的固定代码;它不会发现或编译仓库 JavaScript,运行时也不会导入仓库的其他入口。 + +外层 package manager 仍会运行已配置仓库 package 的生命周期脚本。这里的限制只定义 DSH 所支持的贡献表面;对于用户选择以可执行 package-manager source 安装的仓库,它并不是安全边界。 + +## 运行时组合 + +加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装模块都把自身模块 URL 和已准备的 manifest 委托给该 builtin。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存 package generation 是不可变的。包装模块 dispose 时,会通过正常的 Cordis 子 fiber teardown 移除提供方和所有组合的 MCP client。 + +## 通用 MCP 格式 + +`.mcp.json` 根对象是 `{ "mcpServers": { ... } }`。stdio 条目只接受可选的 `type: "stdio"`、`command`、`args` 和 `env`;HTTP 条目只接受 `type: "http"`、`url` 和 `headers`。字符串值在 Plugin 加载时支持严格的 `${NAME}` 进程环境变量展开;缺失变量会使该次加载失败。HTTP URL 映射到现有 MCP client 的 `streamable-http` transport;stdio 条目以已准备的 package 目录作为 `cwd`。 + +未知字段会被拒绝,包括 OAuth 字段与 `auth` 对象。不提供 `CLAUDE_PLUGIN_ROOT` 展开或兼容层。完成格式转换后,现有 `dsh-mcp-client` 独占 transport 创建、连接诊断、工具同步、调用和断开生命周期;网络或子进程连接失败沿用该 client 既有的“记录错误且不注册工具”行为。 + +## 导出形状 + +Namespace Plugin:具名导出 `name`/`inject`/`apply`、准备阶段常量和 `prepareDshPlugin`,不提供 default export。本包还提供 `dsh-plugin-prepare` 可执行文件和 invariant companion。 + +## 模型体验 + +### Repository skills + +#### 模型看到什么 + +通过 `dsh-tool-skill` 间接呈现:已准备且允许模型调用的 skill 会按其声明的名称和描述进入该消费方记录到日志的目录及所选指令正文表面。消费方的确切 schema 见生成的 [`skill` 工具目录](../../../docs/tool-catalog.md#deepseek-aidsh-tool-skill)。 + +#### Token 影响 + +有条件且随数据变化:每个可见的 repository skill 增加一行受限长度的目录项;加载一个 skill 会把其当前完整指令正文和资源基准指引加入保留的工具历史。 + +#### KV Cache 影响 + +稳定的已准备 Plugin 集合保持前缀稳定。添加、移除或替换 repository Plugin 可能使消费方追加替换目录,并影响后续请求前缀。 + +### Repository MCP 工具 + +#### 模型看到什么 + +通过 `dsh-mcp-client` 间接呈现:每个已连接 server 都贡献带 server 限定名的工具 schema;调用会保留该 client 的规范 MCP 结果和渲染。 + +#### Token 影响 + +取决于连接成功和远端工具列表;schema 会在对应工具视图中的请求上重复出现,而调用与结果会留在历史中直至压缩。 + +#### KV Cache 影响 + +稳定的已连接工具列表保持前缀稳定。Plugin 生命周期或 MCP 工具列表变化可能从首个受影响定义开始改变后续工具 schema 前缀。 + +## 已知限制与延后工作 + +- **仅支持 skills 与 MCP**:commands、hooks、agents、apps、任意 Cordis 代码、marketplace 和兼容 shim 均有意排除在该格式之外。 +- **没有 MCP 认证协议**:静态 header 可以使用环境变量展开,但带 OAuth 的定义会被拒绝,私有 server 登录流程不在此实现。 +- **生成资源是不可变运行时输入**:repository cache generation 不受监视;必须改变 source、ref、path 或配置才能选择另一份已准备 generation。 diff --git a/packages/cordis/repository-plugin/package.json b/packages/cordis/repository-plugin/package.json new file mode 100644 index 0000000000..f6500eb007 --- /dev/null +++ b/packages/cordis/repository-plugin/package.json @@ -0,0 +1,53 @@ +{ + "name": "@deepseek-ai/dsh-repository-plugin", + "description": "Restricted repository plugin format and Cordis runtime for DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "bin": { + "dsh-plugin-prepare": "./lib/bin.js" + }, + "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/bin.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-invariants": "^0.0.1", + "@deepseek-ai/dsh-mcp-client": "^0.0.1", + "@deepseek-ai/dsh-skill-local": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "zod": "^4.4.3" + }, + "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-mcp-client": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/dsh-skill-local": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/cordis/repository-plugin/src/bin.ts b/packages/cordis/repository-plugin/src/bin.ts new file mode 100644 index 0000000000..a1787ff090 --- /dev/null +++ b/packages/cordis/repository-plugin/src/bin.ts @@ -0,0 +1,12 @@ +#!/usr/bin/env node + +/** Command-line entry that prepares the current `.dsh-plugin` package. @module */ + +import { prepareDshPlugin } from './format.ts' + +try { + await prepareDshPlugin() +} catch (error) { + process.stderr.write(`dsh-plugin-prepare: ${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 +} diff --git a/packages/cordis/repository-plugin/src/format.ts b/packages/cordis/repository-plugin/src/format.ts new file mode 100644 index 0000000000..0c00142383 --- /dev/null +++ b/packages/cordis/repository-plugin/src/format.ts @@ -0,0 +1,168 @@ +/** + * Static repository-plugin preparation and prepared-manifest validation. + * @module + */ + +import { cp, copyFile, mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { z } from 'zod' +import { parseMcpDocument } from './mcp.ts' + +/** Fixed module filename loaded from an installed prepared plugin package. */ +export const PREPARED_ENTRY_FILENAME = 'dsh-plugin.mjs' +/** Fixed directory containing copied static plugin assets. */ +export const PREPARED_ASSET_DIRECTORY = 'dsh-plugin-assets' +/** Loader builtin used by every generated import-free wrapper. */ +export const REPOSITORY_PLUGIN_BUILTIN = 'dsh-repository-plugin' + +const sourceMetadataSchema = z.object({ + skills: z.array(z.string().min(1)).default([]), + mcpServers: z.string().min(1).optional(), +}).strict().refine(value => value.skills.length > 0 || value.mcpServers !== undefined, { + message: 'declare at least one skill root or mcpServers file', +}) +const sourcePackageSchema = z.looseObject({ + name: z.string().min(1), + dsh: sourceMetadataSchema, +}) +const preparedManifestSchema = z.object({ + name: z.string().min(1), + skills: z.array(z.string().min(1)), + mcpServers: z.string().min(1).optional(), +}).strict() +const preparedConfigSchema = z.object({ + baseUrl: z.url(), + manifest: preparedManifestSchema, +}).strict() + +/** Static manifest embedded in the generated wrapper. */ +export interface PreparedPluginManifest { + name: string + skills: string[] + mcpServers?: string +} + +/** Untrusted generated-wrapper config accepted by the DSH-owned runtime builtin. */ +export interface PreparedPluginConfig { + baseUrl: string + manifest: PreparedPluginManifest +} + +function formatZodError(label: string, error: z.ZodError): Error { + return new Error(`${label}:\n${z.prettifyError(error)}`) +} + +/** + * Validate the config passed by an installed prepared wrapper. + * @param value - wrapper-provided value crossing the file/module boundary. + * @returns a detached typed config. + */ +export function parsePreparedPluginConfig(value: unknown): PreparedPluginConfig { + const result = preparedConfigSchema.safeParse(value) + if (!result.success) throw formatZodError('invalid prepared DSH plugin', result.error) + return { + baseUrl: result.data.baseUrl, + manifest: { + name: result.data.manifest.name, + skills: result.data.manifest.skills, + ...result.data.manifest.mcpServers === undefined ? {} : { mcpServers: result.data.manifest.mcpServers }, + }, + } +} + +function isOutside(root: string, candidate: string): boolean { + const path = relative(root, candidate) + /* v8 ignore next -- Different-drive Windows relative paths cannot be produced on POSIX coverage hosts. */ + return path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path) +} + +async function sourcePath(pluginDirectory: string, sourceRoot: string, configured: string, kind: 'directory' | 'file'): Promise { + if (isAbsolute(configured)) throw new Error(`DSH plugin asset path must be relative: ${JSON.stringify(configured)}`) + let path: string + try { + path = await realpath(resolve(pluginDirectory, configured)) + } catch (cause) { + throw new Error(`DSH plugin asset does not exist: ${JSON.stringify(configured)}`, { cause }) + } + if (isOutside(sourceRoot, path)) { + throw new Error(`DSH plugin asset escapes its plugin source root: ${JSON.stringify(configured)}`) + } + const info = await stat(path) + if (kind === 'directory' ? !info.isDirectory() : !info.isFile()) { + throw new Error(`DSH plugin asset is not a ${kind}: ${JSON.stringify(configured)}`) + } + return path +} + +function wrapperSource(manifest: PreparedPluginManifest): string { + return [ + '// Generated by dsh-plugin-prepare. Do not edit.', + `const manifest = ${JSON.stringify(manifest)}`, + `export const name = ${JSON.stringify(manifest.name)}`, + "export const inject = ['loader']", + 'export async function apply(ctx) {', + ` const runtime = ctx.loader.builtins[${JSON.stringify(REPOSITORY_PLUGIN_BUILTIN)}]`, + ` if (runtime === undefined) throw new Error(${JSON.stringify(`missing Cordis builtin ${REPOSITORY_PLUGIN_BUILTIN}`)})`, + ' await ctx.plugin(runtime, { baseUrl: import.meta.url, manifest })', + '}', + '', + ].join('\n') +} + +/** + * Validate and package one `.dsh-plugin` directory into static assets plus a fixed wrapper. + * @param directory - `.dsh-plugin` package directory; defaults to the prepare process cwd. + * @returns the generated static manifest. + */ +export async function prepareDshPlugin(directory: string = process.cwd()): Promise { + const pluginDirectory = await realpath(resolve(directory)) + let packageValue: unknown + try { + packageValue = JSON.parse(await readFile(join(pluginDirectory, 'package.json'), 'utf8')) as unknown + } catch (cause) { + throw new Error(`failed to read DSH plugin package metadata in ${pluginDirectory}`, { cause }) + } + const parsed = sourcePackageSchema.safeParse(packageValue) + if (!parsed.success) throw formatZodError('invalid package.json#dsh', parsed.error) + + const sourceRoot = await realpath(dirname(pluginDirectory)) + const skillSources: string[] = [] + for (const configured of parsed.data.dsh.skills) { + const source = await sourcePath(pluginDirectory, sourceRoot, configured, 'directory') + if (!isOutside(source, pluginDirectory)) { + throw new Error(`DSH skill root cannot contain the .dsh-plugin package: ${JSON.stringify(configured)}`) + } + skillSources.push(source) + } + let mcpSource: string | undefined + if (parsed.data.dsh.mcpServers !== undefined) { + mcpSource = await sourcePath(pluginDirectory, sourceRoot, parsed.data.dsh.mcpServers, 'file') + parseMcpDocument(await readFile(mcpSource, 'utf8')) + } + + const manifest: PreparedPluginManifest = { + name: parsed.data.name, + skills: skillSources.map((_, index) => `${PREPARED_ASSET_DIRECTORY}/skills/${index}`), + ...mcpSource === undefined ? {} : { mcpServers: `${PREPARED_ASSET_DIRECTORY}/.mcp.json` }, + } + const staging = await mkdtemp(join(pluginDirectory, '.dsh-plugin-prepare-')) + try { + const stagedAssets = join(staging, PREPARED_ASSET_DIRECTORY) + await mkdir(join(stagedAssets, 'skills'), { recursive: true }) + await Promise.all(skillSources.map((source, index) => cp(source, join(stagedAssets, 'skills', String(index)), { + recursive: true, + force: false, + errorOnExist: true, + }))) + if (mcpSource !== undefined) await copyFile(mcpSource, join(stagedAssets, '.mcp.json')) + await writeFile(join(staging, PREPARED_ENTRY_FILENAME), wrapperSource(manifest)) + + await rm(join(pluginDirectory, PREPARED_ASSET_DIRECTORY), { recursive: true, force: true }) + await rm(join(pluginDirectory, PREPARED_ENTRY_FILENAME), { force: true }) + await rename(stagedAssets, join(pluginDirectory, PREPARED_ASSET_DIRECTORY)) + await rename(join(staging, PREPARED_ENTRY_FILENAME), join(pluginDirectory, PREPARED_ENTRY_FILENAME)) + } finally { + await rm(staging, { recursive: true, force: true }) + } + return manifest +} diff --git a/packages/cordis/repository-plugin/src/index.ts b/packages/cordis/repository-plugin/src/index.ts new file mode 100644 index 0000000000..50a76fc952 --- /dev/null +++ b/packages/cordis/repository-plugin/src/index.ts @@ -0,0 +1,97 @@ +/** + * Restricted repository-plugin runtime for static skills and common MCP definitions. + * @module @deepseek-ai/dsh-repository-plugin + */ + +import { readFile } from 'node:fs/promises' +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Context } from 'cordis' +import type {} from '@cordisjs/plugin-loader' +import * as SkillLocal from '@deepseek-ai/dsh-skill-local' +import * as McpClient from '@deepseek-ai/dsh-mcp-client' +import { + REPOSITORY_PLUGIN_BUILTIN, + parsePreparedPluginConfig, + type PreparedPluginConfig, +} from './format.ts' +import { parseMcpDocument, resolveMcpServers } from './mcp.ts' + +export { + PREPARED_ASSET_DIRECTORY, + PREPARED_ENTRY_FILENAME, + REPOSITORY_PLUGIN_BUILTIN, + prepareDshPlugin, + type PreparedPluginManifest, +} from './format.ts' + +/** Cordis plugin name used by Loader diagnostics. */ +export const name = 'repository-plugin' +/** Loader service required to register the fixed prepared-wrapper builtin. */ +export const inject = ['loader'] + +function preparedPath(baseUrl: string, configured: string): string { + if (isAbsolute(configured)) throw new Error(`prepared DSH plugin path must be relative: ${JSON.stringify(configured)}`) + const directory = dirname(fileURLToPath(baseUrl)) + const path = resolve(directory, configured) + const rel = relative(directory, path) + /* v8 ignore next -- Different-drive Windows relative paths cannot be produced on POSIX coverage hosts. */ + if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw new Error(`prepared DSH plugin path escapes its package: ${JSON.stringify(configured)}`) + } + return path +} + +async function applyPrepared(ctx: Context, value: PreparedPluginConfig): Promise { + const config = parsePreparedPluginConfig(value) + const directory = dirname(fileURLToPath(config.baseUrl)) + const skillDirectories = config.manifest.skills.map(path => preparedPath(config.baseUrl, path)) + const mcpConfigs = config.manifest.mcpServers === undefined + ? [] + : resolveMcpServers( + parseMcpDocument(await readFile(preparedPath(config.baseUrl, config.manifest.mcpServers), 'utf8')), + process.env, + directory, + ).map(input => McpClient.Config(input as never)) + + await ctx.effect(async function* () { + if (skillDirectories.length > 0) { + const skills = ctx.plugin(SkillLocal, { + providerName: `repository:${config.manifest.name}`, + includeDefaultRoots: false, + customSkillDirs: skillDirectories, + watch: false, + }) + await skills + yield skills.dispose + } + for (const mcpConfig of mcpConfigs) { + const mcp = ctx.plugin(McpClient, mcpConfig) + await mcp + yield mcp.dispose + } + }, `repository-plugin(${config.manifest.name})`) +} + +const preparedRuntime = { + name: 'repository-plugin-runtime', + apply: applyPrepared, +} + +/** + * Register the DSH-owned runtime as the Loader builtin used by fixed prepared wrappers. + * @param ctx - plugin context carrying the Loader service. + */ +export function apply(ctx: Context): void { + if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] !== undefined) { + throw new Error(`Loader builtin ${REPOSITORY_PLUGIN_BUILTIN} is already registered`) + } + ctx.effect(function* () { + ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] = preparedRuntime + yield () => { + if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] === preparedRuntime) { + Reflect.deleteProperty(ctx.loader.builtins, REPOSITORY_PLUGIN_BUILTIN) + } + } + }, 'repository-plugin Loader builtin') +} diff --git a/packages/cordis/repository-plugin/src/invariant.ts b/packages/cordis/repository-plugin/src/invariant.ts new file mode 100644 index 0000000000..410e8bf69e --- /dev/null +++ b/packages/cordis/repository-plugin/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-repository-plugin`. + * @module @deepseek-ai/dsh-repository-plugin/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-repository-plugin' + +/** Cordis companion plugin name. */ +export const name = 'repository-plugin-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the package owns no service state; Loader fibers and the existing skill + * and MCP owners expose the authoritative lifecycle relationships for its composed children. + */ +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/cordis/repository-plugin/src/mcp.ts b/packages/cordis/repository-plugin/src/mcp.ts new file mode 100644 index 0000000000..893d96f086 --- /dev/null +++ b/packages/cordis/repository-plugin/src/mcp.ts @@ -0,0 +1,145 @@ +/** + * Parser for the common `.mcp.json` file consumed by prepared repository plugins. + * @module + */ + +import { z } from 'zod' + +const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/ +const ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ +const PLACEHOLDER_PATTERN = /\$\{([^}]*)\}/g + +const stringMap = z.record(z.string(), z.string()) +const stdioServerSchema = z.object({ + type: z.literal('stdio').optional(), + command: z.string().min(1), + args: z.array(z.string()).optional(), + env: stringMap.optional(), +}).strict() +const httpServerSchema = z.object({ + type: z.literal('http'), + url: z.string().min(1), + headers: stringMap.optional(), +}).strict() +const documentSchema = z.object({ + mcpServers: z.record(z.string(), z.union([stdioServerSchema, httpServerSchema])), +}).strict() + +/** One supported server entry from the common `.mcp.json` format. */ +export type McpServerDefinition = z.infer | z.infer + +/** Parsed common MCP document before process-environment expansion. */ +export interface McpDocument { + mcpServers: Record +} + +/** Resolved input handed to the existing `dsh-mcp-client` Config schema. */ +export type ResolvedMcpServer = + | { + transport: 'stdio' + serverName: string + command: string + args: string[] + env: Record + cwd: string + } + | { + transport: 'streamable-http' + serverName: string + url: string + headers: Record + } + +function assertTemplate(value: string, location: string): void { + for (const match of value.matchAll(PLACEHOLDER_PATTERN)) { + const name = match[1] as string + if (!ENVIRONMENT_NAME_PATTERN.test(name)) { + throw new Error(`${location} contains an unsupported environment placeholder ${JSON.stringify(match[0])}`) + } + } + if (value.replace(PLACEHOLDER_PATTERN, '').includes('${')) { + throw new Error(`${location} contains an unterminated environment placeholder`) + } +} + +function visitStrings(serverName: string, definition: McpServerDefinition, visit: (value: string, location: string) => void): void { + if ('command' in definition) { + visit(definition.command, `mcpServers.${serverName}.command`) + definition.args?.forEach((value, index) => { visit(value, `mcpServers.${serverName}.args[${index}]`) }) + Object.entries(definition.env ?? {}).forEach(([name, value]) => { visit(value, `mcpServers.${serverName}.env.${name}`) }) + return + } + visit(definition.url, `mcpServers.${serverName}.url`) + Object.entries(definition.headers ?? {}).forEach(([name, value]) => { visit(value, `mcpServers.${serverName}.headers.${name}`) }) +} + +/** + * Parse and validate one common `.mcp.json` document without resolving environment values. + * @param content - UTF-8 JSON document. + * @returns the supported stdio and Streamable HTTP server definitions. + */ +export function parseMcpDocument(content: string): McpDocument { + let value: unknown + try { + value = JSON.parse(content) as unknown + } catch (cause) { + throw new Error('invalid .mcp.json: expected JSON', { cause }) + } + const result = documentSchema.safeParse(value) + if (!result.success) throw new Error(`invalid .mcp.json:\n${z.prettifyError(result.error)}`) + for (const [serverName, definition] of Object.entries(result.data.mcpServers)) { + if (!SERVER_NAME_PATTERN.test(serverName)) { + throw new Error(`invalid .mcp.json: server name ${JSON.stringify(serverName)} must match [A-Za-z0-9_-]{1,32}`) + } + visitStrings(serverName, definition, assertTemplate) + } + return result.data +} + +function expand(value: string, environment: NodeJS.ProcessEnv, location: string): string { + return value.replace(PLACEHOLDER_PATTERN, (_placeholder, name: string) => { + const replacement = environment[name] + if (replacement === undefined) throw new Error(`${location} requires missing environment variable ${name}`) + return replacement + }) +} + +function expandMap(values: Record | undefined, environment: NodeJS.ProcessEnv, location: string): Record { + return Object.fromEntries(Object.entries(values ?? {}).map(([name, value]) => [ + name, + expand(value, environment, `${location}.${name}`), + ])) +} + +/** + * Resolve supported MCP definitions to inputs for the existing MCP client. + * @param document - validated common MCP document. + * @param environment - process environment used for exact `${NAME}` expansion. + * @param cwd - prepared plugin directory used for stdio child processes. + * @returns one existing-client config input per declared server. + */ +export function resolveMcpServers(document: McpDocument, environment: NodeJS.ProcessEnv, cwd: string): ResolvedMcpServer[] { + return Object.entries(document.mcpServers).map(([serverName, definition]) => { + if ('command' in definition) { + return { + transport: 'stdio', + serverName, + command: expand(definition.command, environment, `mcpServers.${serverName}.command`), + args: (definition.args ?? []).map((value, index) => expand(value, environment, `mcpServers.${serverName}.args[${index}]`)), + env: expandMap(definition.env, environment, `mcpServers.${serverName}.env`), + cwd, + } + } + const url = expand(definition.url, environment, `mcpServers.${serverName}.url`) + const protocol = new URL(url).protocol + if (protocol !== 'http:' && protocol !== 'https:') { + throw new Error(`mcpServers.${serverName}.url must use http or https`) + } + return { + transport: 'streamable-http', + serverName, + url, + headers: expandMap(definition.headers, environment, `mcpServers.${serverName}.headers`), + } + }) +} diff --git a/packages/cordis/repository-plugin/tests/mcp-format.spec.ts b/packages/cordis/repository-plugin/tests/mcp-format.spec.ts new file mode 100644 index 0000000000..094cb93020 --- /dev/null +++ b/packages/cordis/repository-plugin/tests/mcp-format.spec.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { parseMcpDocument, resolveMcpServers } from '../src/mcp.ts' + +describe('repository plugin common .mcp.json support', () => { + it('maps Expo-style HTTP servers to the existing Streamable HTTP client config', () => { + const document = parseMcpDocument(JSON.stringify({ + mcpServers: { + expo: { type: 'http', url: 'https://mcp.expo.dev/mcp' }, + }, + })) + + expect(resolveMcpServers(document, {}, '/plugin')).toEqual([{ + transport: 'streamable-http', + serverName: 'expo', + url: 'https://mcp.expo.dev/mcp', + headers: {}, + }]) + }) + + it('maps DataJunction-style stdio servers and expands exact environment placeholders', () => { + const document = parseMcpDocument(JSON.stringify({ + mcpServers: { + datajunction: { + command: 'dj-mcp', + args: ['--endpoint', '${DJ_API_URL}'], + env: { DJ_API_URL: '${DJ_API_URL}' }, + }, + }, + })) + + expect(resolveMcpServers(document, { DJ_API_URL: 'http://localhost:8000' }, '/plugin')).toEqual([{ + transport: 'stdio', + serverName: 'datajunction', + command: 'dj-mcp', + args: ['--endpoint', 'http://localhost:8000'], + env: { DJ_API_URL: 'http://localhost:8000' }, + cwd: '/plugin', + }]) + }) + + it('fails loud when a declared environment value is absent', () => { + const document = parseMcpDocument(JSON.stringify({ + mcpServers: { datajunction: { command: 'dj-mcp', env: { DJ_API_URL: '${DJ_API_URL}' } } }, + })) + + expect(() => resolveMcpServers(document, {}, '/plugin')).toThrow('missing environment variable DJ_API_URL') + }) + + it('accepts explicit stdio defaults and expands HTTP URLs and headers', () => { + const document = parseMcpDocument(JSON.stringify({ + mcpServers: { + local: { type: 'stdio', command: 'local-mcp' }, + remote: { + type: 'http', + url: 'http://${MCP_HOST}/mcp', + headers: { Authorization: 'Bearer ${MCP_TOKEN}' }, + }, + }, + })) + + expect(resolveMcpServers(document, { MCP_HOST: 'localhost:3000', MCP_TOKEN: 'test-token' }, '/plugin')).toEqual([ + { + transport: 'stdio', + serverName: 'local', + command: 'local-mcp', + args: [], + env: {}, + cwd: '/plugin', + }, + { + transport: 'streamable-http', + serverName: 'remote', + url: 'http://localhost:3000/mcp', + headers: { Authorization: 'Bearer test-token' }, + }, + ]) + }) + + it('rejects malformed JSON, server names, placeholders, and non-HTTP URLs', () => { + expect(() => parseMcpDocument('{')).toThrow('expected JSON') + expect(() => parseMcpDocument(JSON.stringify({ + mcpServers: { 'bad name': { command: 'server' } }, + }))).toThrow('server name') + expect(() => parseMcpDocument(JSON.stringify({ + mcpServers: { bad: { command: '${BAD-NAME}' } }, + }))).toThrow('unsupported environment placeholder') + expect(() => parseMcpDocument(JSON.stringify({ + mcpServers: { bad: { command: '${UNFINISHED' } }, + }))).toThrow('unterminated environment placeholder') + const ftp = parseMcpDocument(JSON.stringify({ + mcpServers: { remote: { type: 'http', url: 'ftp://example.test/mcp' } }, + })) + expect(() => resolveMcpServers(ftp, {}, '/plugin')).toThrow('must use http or https') + }) + + it('rejects Work IQ OAuth fields instead of treating them as unauthenticated HTTP', () => { + expect(() => parseMcpDocument(JSON.stringify({ + mcpServers: { + workiq: { + type: 'http', + url: 'https://workiq.microsoft.com/mcp', + oauthClientId: 'client-id', + oauthPublicClient: true, + auth: { redirectPort: 3317 }, + }, + }, + }))).toThrow('invalid .mcp.json') + }) +}) diff --git a/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts b/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts new file mode 100644 index 0000000000..f8e5807501 --- /dev/null +++ b/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts @@ -0,0 +1,243 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, relative, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import SkillService from '@deepseek-ai/dsh-skill' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as RepositoryPlugin from '@deepseek-ai/dsh-repository-plugin' +import * as RepositoryPluginInvariant from '@deepseek-ai/dsh-repository-plugin/invariant' +import { parsePreparedPluginConfig } from '../src/format.ts' + +const roots: string[] = [] + +async function temporaryDirectory(name: string): Promise { + const directory = await mkdtemp(join(tmpdir(), `dsh-repository-plugin-${name}-`)) + roots.push(directory) + return directory +} + +async function writePlugin(root: string, name: string, dsh: Record): Promise { + const directory = join(root, '.dsh-plugin') + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'package.json'), `${JSON.stringify({ name, version: '0.0.0', dsh }, undefined, 2)}\n`) + return directory +} + +async function writeSkill(root: string, name: string): Promise { + const directory = join(root, name) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'SKILL.md'), `---\nname: ${name}\ndescription: Repository fixture skill.\n---\n\nStatic instructions.\n`) +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +describe('dsh-plugin-prepare', () => { + it('copies declared static assets and emits the fixed import-free wrapper', async () => { + const root = await temporaryDirectory('prepare') + await writeSkill(join(root, 'skills'), 'repository-fixture') + await writeFile(join(root, '.mcp.json'), JSON.stringify({ + mcpServers: { + expo: { type: 'http', url: 'https://mcp.expo.dev/mcp' }, + }, + })) + const directory = await writePlugin(root, 'fixture-plugin', { + skills: ['../skills'], + mcpServers: '../.mcp.json', + }) + + await expect(RepositoryPlugin.prepareDshPlugin(directory)).resolves.toEqual({ + name: 'fixture-plugin', + skills: ['dsh-plugin-assets/skills/0'], + mcpServers: 'dsh-plugin-assets/.mcp.json', + }) + const wrapper = await readFile(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME), 'utf8') + expect(wrapper).toContain(`ctx.loader.builtins["${RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN}"]`) + expect(wrapper).not.toMatch(/\b(?:import|from)\s/) + await expect(readFile(join(directory, 'dsh-plugin-assets/skills/0/repository-fixture/SKILL.md'), 'utf8')) + .resolves.toContain('Static instructions.') + await expect(readFile(join(directory, 'dsh-plugin-assets/.mcp.json'), 'utf8')) + .resolves.toContain('mcp.expo.dev') + }) + + it('rejects unsupported OAuth MCP metadata before publishing outputs', async () => { + const root = await temporaryDirectory('oauth') + await writeFile(join(root, '.mcp.json'), JSON.stringify({ + mcpServers: { + workiq: { + type: 'http', + url: 'https://workiq.microsoft.com/mcp', + oauthClientId: 'client-id', + oauthPublicClient: true, + auth: { redirectPort: 3317 }, + }, + }, + })) + const directory = await writePlugin(root, 'unsupported-oauth', { mcpServers: '../.mcp.json' }) + + await expect(RepositoryPlugin.prepareDshPlugin(directory)).rejects.toThrow('invalid .mcp.json') + await expect(readFile(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('rejects invalid metadata, missing assets, wrong asset types, and escaped paths', async () => { + const malformedRoot = await temporaryDirectory('malformed-package') + const malformed = join(malformedRoot, '.dsh-plugin') + await mkdir(malformed) + await writeFile(join(malformed, 'package.json'), '{') + await expect(RepositoryPlugin.prepareDshPlugin(malformed)).rejects.toThrow('failed to read DSH plugin package metadata') + + const emptyRoot = await temporaryDirectory('empty-metadata') + const empty = await writePlugin(emptyRoot, 'empty', {}) + await expect(RepositoryPlugin.prepareDshPlugin(empty)).rejects.toThrow('declare at least one skill root or mcpServers file') + + const missingRoot = await temporaryDirectory('missing-asset') + const missing = await writePlugin(missingRoot, 'missing', { skills: ['../missing'] }) + await expect(RepositoryPlugin.prepareDshPlugin(missing)).rejects.toThrow('asset does not exist') + + const absoluteRoot = await temporaryDirectory('absolute-asset') + const absolute = await writePlugin(absoluteRoot, 'absolute', { skills: [absoluteRoot] }) + await expect(RepositoryPlugin.prepareDshPlugin(absolute)).rejects.toThrow('asset path must be relative') + + const wrongTypeRoot = await temporaryDirectory('wrong-type') + await writeFile(join(wrongTypeRoot, 'not-a-directory'), 'text') + const wrongType = await writePlugin(wrongTypeRoot, 'wrong-type', { skills: ['../not-a-directory'] }) + await expect(RepositoryPlugin.prepareDshPlugin(wrongType)).rejects.toThrow('asset is not a directory') + + const wrongMcpRoot = await temporaryDirectory('wrong-mcp-type') + await mkdir(join(wrongMcpRoot, 'not-a-file')) + const wrongMcp = await writePlugin(wrongMcpRoot, 'wrong-mcp', { mcpServers: '../not-a-file' }) + await expect(RepositoryPlugin.prepareDshPlugin(wrongMcp)).rejects.toThrow('asset is not a file') + + const containingRoot = await temporaryDirectory('containing-root') + const containing = await writePlugin(containingRoot, 'containing', { skills: ['..'] }) + await expect(RepositoryPlugin.prepareDshPlugin(containing)).rejects.toThrow('cannot contain the .dsh-plugin package') + + const escapedRoot = await temporaryDirectory('escaped-root') + const outside = await temporaryDirectory('outside-root') + await writeSkill(outside, 'outside-skill') + const escaped = await writePlugin(escapedRoot, 'escaped', { skills: [relative(join(escapedRoot, '.dsh-plugin'), outside)] }) + await expect(RepositoryPlugin.prepareDshPlugin(escaped)).rejects.toThrow('escapes its plugin source root') + }) + + it('validates prepared wrapper configs with and without MCP assets', () => { + expect(() => parsePreparedPluginConfig({})).toThrow('invalid prepared DSH plugin') + expect(parsePreparedPluginConfig({ + baseUrl: 'file:///plugin/dsh-plugin.mjs', + manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json' }, + })).toEqual({ + baseUrl: 'file:///plugin/dsh-plugin.mjs', + manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json' }, + }) + }) +}) + +describe('prepared repository plugin Loader composition', () => { + it('mounts and removes copied skills through the real Loader and skill-local provider', async () => { + const root = await temporaryDirectory('loader') + await writeSkill(join(root, 'skills'), 'loaded-from-repository') + const directory = await writePlugin(root, 'loader-fixture', { skills: ['../skills'] }) + await RepositoryPlugin.prepareDshPlugin(directory) + + const ctx = new Context() + ctx.baseUrl = pathToFileURL(directory).href + '/' + await ctx.plugin(Loader) + await ctx.plugin(SkillService) + const registrar = ctx.plugin(RepositoryPlugin) + await registrar + expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeDefined() + + const id = await ctx.loader.create({ + name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href, + }) + await ctx.loader.await() + await expect(ctx.skills.get('loaded-from-repository')).resolves.toMatchObject({ + name: 'loaded-from-repository', + provider: 'repository:loader-fixture', + content: 'Static instructions.', + }) + + await ctx.loader.remove(id) + await expect(ctx.skills.get('loaded-from-repository')).resolves.toBeUndefined() + await registrar.dispose() + expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('delegates an MCP-only plugin to the existing client without turning connect failure into Loader failure', async () => { + const root = await temporaryDirectory('mcp-loader') + await writeFile(join(root, '.mcp.json'), JSON.stringify({ + mcpServers: { offline: { command: join(root, 'missing-mcp-command') } }, + })) + const directory = await writePlugin(root, 'mcp-loader-fixture', { mcpServers: '../.mcp.json' }) + await RepositoryPlugin.prepareDshPlugin(directory) + + const ctx = new Context() + ctx.baseUrl = pathToFileURL(directory).href + '/' + await ctx.plugin(Loader) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(RepositoryPlugin) + const id = await ctx.loader.create({ + name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href, + }) + await ctx.loader.await() + expect(ctx.tools.schemas().some(tool => tool.name.startsWith('mcp__offline__'))).toBe(false) + await ctx.loader.remove(id) + await ctx.fiber.dispose() + }) + + it('rejects hostile prepared paths before mounting children', async () => { + const root = await temporaryDirectory('prepared-paths') + const ctx = new Context() + ctx.baseUrl = pathToFileURL(root).href + '/' + await ctx.plugin(Loader) + await ctx.plugin(RepositoryPlugin) + + for (const [filename, skillPath] of [ + ['absolute.mjs', resolve(root)], + ['escaped.mjs', '../outside'], + ] as const) { + const wrapper = join(root, filename) + await writeFile(wrapper, [ + "export const inject = ['loader']", + 'export async function apply(ctx) {', + ` await ctx.plugin(ctx.loader.builtins['${RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN}'], {`, + ` baseUrl: import.meta.url, manifest: { name: 'hostile', skills: [${JSON.stringify(skillPath)}] },`, + ' })', + '}', + '', + ].join('\n')) + await expect(ctx.loader.create({ name: pathToFileURL(wrapper).href })).rejects.toThrow('prepared DSH plugin path') + } + await ctx.fiber.dispose() + }) + + it('rejects duplicate builtin ownership and preserves a later replacement on teardown', async () => { + const ctx = new Context() + await ctx.plugin(Loader) + const registrar = ctx.plugin(RepositoryPlugin) + await registrar + expect(() => { RepositoryPlugin.apply(ctx) }).toThrow('already registered') + + const replacement = { name: 'replacement', apply() {} } + ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN] = replacement + await registrar.dispose() + expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBe(replacement) + await ctx.fiber.dispose() + }) +}) + +describe('repository plugin invariant companion', () => { + it('registers its explained empty invariant', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(RepositoryPluginInvariant).await()).resolves.toBeDefined() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/cordis/repository-plugin/tsconfig.json b/packages/cordis/repository-plugin/tsconfig.json new file mode 100644 index 0000000000..f7918dcdd9 --- /dev/null +++ b/packages/cordis/repository-plugin/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../skill/skill-local" + }, + { + "path": "../../mcp/mcp-client" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/cordis/repository-plugin/tsdown.config.ts b/packages/cordis/repository-plugin/tsdown.config.ts new file mode 100644 index 0000000000..ac8e9a5fe0 --- /dev/null +++ b/packages/cordis/repository-plugin/tsdown.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'tsdown' + +/** Build the runtime, invariant, and prepare executable as self-contained entries. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, + { + entry: ['lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, + { + entry: ['lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, +]) diff --git a/packages/skill/skill-local/README.i18n.yaml b/packages/skill/skill-local/README.i18n.yaml index d1fa4602be..1902122c68 100644 --- a/packages/skill/skill-local/README.i18n.yaml +++ b/packages/skill/skill-local/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/skill/skill-local/README.md -README.md: 2077cf852fe90f7a0fec4e9bda1e9ff68fc56453 -README.zh.md: ba1c71f1bc1916daad82d872ae6658bb203133c9 +README.md: 836a2a631e9e6e452a11e3cffc102de355f1c5d9 +README.zh.md: 2e2cc45ad80f760e04f813b7ee85932b51b1df05 diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index 2077cf852f..836a2a631e 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -14,6 +14,8 @@ Requires `ctx.skills` (`inject: ['skills']`). | Field | Default | Meaning | |---|---|---| +| `providerName` | `local` | Unique name used to register this provider on `ctx.skills`. | +| `includeDefaultRoots` | `true` | Include project and user roots around `customSkillDirs`; set false for an isolated custom-root provider. | | `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md); scans `skills` under this directory. | | `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | Shared agent config root scanned for compatible skills. | | `customSkillDirs` | `[]` | Additional local skill roots scanned after project roots and before user roots. | @@ -36,7 +38,7 @@ Default roots are resolved in this provider's rank order: | 400 | `user-dsh` | `/skills` | | 500 | `user-agents` | `/skills` | -The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not treated as normal user skills. This provider supplies project and user skills; another provider may supply built-in system skills. +The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not treated as normal user skills. `includeDefaultRoots: false` omits both project and user rows while retaining explicitly configured custom and bundled roots, allowing several uniquely named isolated providers such as immutable repository Plugins. This provider supplies project and user skills; another provider may supply built-in system skills. When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and probes `.git` through the filesystem service. Full skill loads forward the lookup abort signal to filesystem metadata and content reads. Without a filesystem service, the provider falls back to abortable Node filesystem I/O so minimal local contexts can still load skills. Confirmed missing paths are valid empty state, malformed or non-text entries warn and skip, and unexpected discovery/read failures make the registry snapshot incomplete rather than replacing a last-good model catalog with a misleading deletion. diff --git a/packages/skill/skill-local/README.zh.md b/packages/skill/skill-local/README.zh.md index ba1c71f1bc..2e2cc45ad8 100644 --- a/packages/skill/skill-local/README.zh.md +++ b/packages/skill/skill-local/README.zh.md @@ -14,6 +14,8 @@ | 字段 | 默认值 | 含义 | |---|---|---| +| `providerName` | `local` | 在 `ctx.skills` 上注册该提供方时使用的唯一名称。 | +| `includeDefaultRoots` | `true` | 在 `customSkillDirs` 周围包含项目根和用户根;设为 false 时仅使用隔离的自定义根。 | | `dshHome` | `$DSH_HOME` 或 `~/.dsh` | 由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 DeepSeek Harness 配置根目录;扫描该目录下的 `skills`。 | | `agentsHome` | `$DSH_AGENTS_HOME` 或 `~/.agents` | 为兼容 skill 扫描的共享 agent(智能体)配置根目录。 | | `customSkillDirs` | `[]` | 在项目根目录之后、用户根目录之前扫描的其他本地 skill 根目录。 | @@ -36,7 +38,7 @@ | 400 | `user-dsh` | `/skills` | | 500 | `user-agents` | `/skills` | -项目根目录是包含 `.git` 的最近祖先目录;如果不存在,则使用当前 cwd。用户 DSH 根目录会跳过其 `.system` 子目录,因此归系统所有的目录不会被当作普通用户 skill。该提供方提供项目和用户 skill;其他提供方可提供内置系统 skill。 +项目根目录是包含 `.git` 的最近祖先目录;如果不存在,则使用当前 cwd。用户 DSH 根目录会跳过其 `.system` 子目录,因此归系统所有的目录不会被当作普通用户 skill。`includeDefaultRoots: false` 会省略项目和用户两类根,同时保留显式配置的自定义根与 bundled 根,因此可以挂载多个唯一命名的隔离提供方,例如不可变 repository Plugin。该提供方提供项目和用户 skill;其他提供方可提供内置系统 skill。 当 `ctx.fs` 可用时,发现通过 `ctx.fs.listDir` 列出根,通过 `ctx.fs.readText` 读取 skill 文件,并通过文件系统服务探测 `.git`。完整 skill 加载会将查找中止信号转发给文件系统元数据和内容读取。如果没有文件系统服务,提供方回退到可中止的 Node 文件系统 I/O,使最小本地上下文仍能加载 skill。已确认缺失的路径属于有效空状态;格式错误或非文本条目会警告并跳过;意外的发现或读取失败会使注册表快照不完整,系统不会因此用看似发生删除的结果替换上一份可用模型目录。 diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index a19fa1dde5..aa07443a56 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -47,6 +47,10 @@ export const inject = ['skills'] /** Local filesystem skill provider configuration. */ export interface Config { + /** Unique provider name. Defaults to `local`. */ + providerName?: string + /** Whether project and user roots are included around custom roots. */ + includeDefaultRoots?: boolean /** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */ dshHome?: string /** Shared agent config root. Defaults to `$DSH_AGENTS_HOME` or `~/.agents`. */ @@ -70,6 +74,8 @@ export interface Config { } export const Config: Schema = z.object({ + providerName: z.string().min(1).default('local'), + includeDefaultRoots: z.boolean().default(true), dshHome: z.string(), agentsHome: z.string(), customSkillDirs: z.array(z.string()).default([]), @@ -138,7 +144,8 @@ export function apply(ctx: Context, config: Config = {}): void { /** Provider that maps local project/user skill roots into `ctx.skills`. */ export class LocalSkillProvider implements SkillProvider { - readonly name = 'local' + readonly name: string + private readonly includeDefaultRoots: boolean private readonly dshHome: string private readonly agentsHome: string private readonly customSkillDirs: string[] @@ -151,6 +158,8 @@ export class LocalSkillProvider implements SkillProvider { control: SkillProviderControl, config: Config = {}, ) { + this.name = config.providerName ?? 'local' + this.includeDefaultRoots = config.includeDefaultRoots ?? true this.dshHome = resolveDshHome(config.dshHome) this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents')) this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root)) @@ -177,7 +186,7 @@ export class LocalSkillProvider implements SkillProvider { } const candidates: SkillCandidate[] = [] for (const root of roots) { - for (const skill of await discoverRoot(root, this.ctx)) { + for (const skill of await discoverRoot(root, this.ctx, this.name)) { candidates.push(skill) } } @@ -227,21 +236,23 @@ export class LocalSkillProvider implements SkillProvider { private async roots(cwd: string | undefined): Promise { const roots: SkillRoot[] = [] - if (cwd !== undefined) { + if (this.includeDefaultRoots && cwd !== undefined) { const projectRoot = await findProjectRoot(resolve(cwd), optionalFileSystem(this.ctx)) roots.push( { path: join(projectRoot, '.dsh/skills'), source: 'project-dsh', rank: PROJECT_DSH_RANK, projectRoot }, { path: join(projectRoot, '.agents/skills'), source: 'project-agents', rank: PROJECT_AGENTS_RANK, projectRoot }, ) } - roots.push( - ...this.customSkillDirs.map(path => ({ path, source: 'custom' as const, rank: CUSTOM_RANK })), - { path: join(this.dshHome, 'skills'), source: 'user-dsh', rank: USER_DSH_RANK, skipSystem: true }, - { path: join(this.agentsHome, 'skills'), source: 'user-agents', rank: USER_AGENTS_RANK }, - ...this.bundledSkillDir === undefined - ? [] - : [{ path: this.bundledSkillDir, source: 'bundled' as const, rank: BUNDLED_RANK, trustedHost: true }], - ) + roots.push(...this.customSkillDirs.map(path => ({ path, source: 'custom' as const, rank: CUSTOM_RANK }))) + if (this.includeDefaultRoots) { + roots.push( + { path: join(this.dshHome, 'skills'), source: 'user-dsh', rank: USER_DSH_RANK, skipSystem: true }, + { path: join(this.agentsHome, 'skills'), source: 'user-agents', rank: USER_AGENTS_RANK }, + ) + } + if (this.bundledSkillDir !== undefined) { + roots.push({ path: this.bundledSkillDir, source: 'bundled', rank: BUNDLED_RANK, trustedHost: true }) + } return roots } } @@ -693,7 +704,7 @@ function hasErrorCode(error: unknown, code: string): boolean { return typeof error === 'object' && error !== null && 'code' in error && error.code === code } -async function discoverRoot(root: SkillRoot, ctx: Context): Promise { +async function discoverRoot(root: SkillRoot, ctx: Context, provider: string): Promise { const skills: SkillCandidate[] = [] const entries = await listSkillRootEntries(root, ctx) for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { @@ -711,7 +722,7 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise Date: Thu, 30 Jul 2026 04:54:52 +0800 Subject: [PATCH 16/23] fix(lockfile): link repository plugin to vendored Cordis --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3c6871303..4eb0aedbfb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2382,7 +2382,7 @@ importers: 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@vendor+loader) + version: link:../../../vendor/cordis packages/cordis/tool-cordis: dependencies: From a0aed8a19fe4c0e9bc29f5a842403526d74d064e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 06:30:09 +0800 Subject: [PATCH 17/23] fix(examples): own repository plugin startup order --- .../headless-agent/tests/fixtures/cli.cordis.yml | 5 +---- .../tests/fixtures/repository-plugin/load.mjs | 13 +++++++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 examples/headless-agent/tests/fixtures/repository-plugin/load.mjs diff --git a/examples/headless-agent/tests/fixtures/cli.cordis.yml b/examples/headless-agent/tests/fixtures/cli.cordis.yml index 5e1a296de5..72e71ec775 100644 --- a/examples/headless-agent/tests/fixtures/cli.cordis.yml +++ b/examples/headless-agent/tests/fixtures/cli.cordis.yml @@ -1,11 +1,8 @@ - id: cli-mock-llm name: './cli-mock-llm.ts' -- id: repository-plugin-runtime - name: '@deepseek-ai/dsh-repository-plugin' - - id: repository-plugin-fixture - name: './repository-plugin/dsh-plugin.mjs' + name: './repository-plugin/load.mjs' - id: base name: '@cordisjs/plugin-include' diff --git a/examples/headless-agent/tests/fixtures/repository-plugin/load.mjs b/examples/headless-agent/tests/fixtures/repository-plugin/load.mjs new file mode 100644 index 0000000000..90f759876a --- /dev/null +++ b/examples/headless-agent/tests/fixtures/repository-plugin/load.mjs @@ -0,0 +1,13 @@ +/** + * Keyless fixture owner that mounts the runtime before its prepared wrapper. + * Cordis starts sibling Loader entries concurrently, so row order is not a dependency edge. + */ +import * as RepositoryPlugin from '@deepseek-ai/dsh-repository-plugin' +import * as PreparedPlugin from './dsh-plugin.mjs' + +export const name = 'headless-repository-fixture-loader' + +export async function apply(ctx) { + await ctx.plugin(RepositoryPlugin) + await ctx.plugin(PreparedPlugin) +} From 0664b25cd955aa8b4f5a80bc29ca4c872ba4802e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:16:23 +0800 Subject: [PATCH 18/23] fix(review): validate skill roots at mount and isolate provider default roots ds-review-bot round 1 on the repository-plugin runtime: - a manifest-declared skill root absent or non-directory in the installed package now fails the plugin load (skill-local treats a missing root as legitimately empty, which silently mounted a skill-less plugin) - includeDefaultRoots: false no longer inherits $DSH_BUNDLED_SKILL_DIR, so isolated repository providers see only their explicit roots - prepared wrapper baseUrl schema requires the file: scheme, failing hostile URLs at the declared validation boundary - preparedPath reuses format.ts's isOutside; SERVER_NAME_PATTERN is exported and pinned equal to dsh-mcp-client's, with the restatement justified (the prepare bin keeps a zod-only module graph); the unexplained `as never` cast now carries its schemastery rationale - the import-free wrapper assertion also rejects dynamic import( - the headless fixture wrapper is regenerated by the real prepareDshPlugin and a drift test pins fixture == generator output - prepareDshPlugin JSDoc states the non-atomic publish repair contract --- ...-static-repository-plugin-format.i18n.yaml | 4 +-- ...6-07-30-static-repository-plugin-format.md | 2 +- ...7-30-static-repository-plugin-format.zh.md | 2 +- docs/config-catalog.md | 4 +-- .../fixtures/repository-plugin/dsh-plugin.mjs | 10 +++--- .../headless-agent/tests/keyless-smoke.e2e.ts | 28 +++++++++++++++- .../cordis/repository-plugin/README.i18n.yaml | 4 +-- packages/cordis/repository-plugin/README.md | 2 +- .../cordis/repository-plugin/README.zh.md | 2 +- .../cordis/repository-plugin/src/format.ts | 31 ++++++++++++++++-- .../cordis/repository-plugin/src/index.ts | 31 ++++++++++++++---- packages/cordis/repository-plugin/src/mcp.ts | 11 +++++-- .../tests/mcp-format.spec.ts | 10 +++++- .../tests/repository-plugin.spec.ts | 32 ++++++++++++++++++- packages/mcp/mcp-client/src/index.ts | 5 ++- packages/skill/skill-local/README.i18n.yaml | 4 +-- packages/skill/skill-local/README.md | 2 +- packages/skill/skill-local/README.zh.md | 2 +- packages/skill/skill-local/src/index.ts | 9 ++++-- .../skill-local/tests/skill-local.spec.ts | 16 ++++++++++ 20 files changed, 175 insertions(+), 36 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml index 0190e19f8e..b3fbb54f35 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.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-30-static-repository-plugin-format.md -2026-07-30-static-repository-plugin-format.md: c4739a6843db515d4cd67441e74bbf05228c612a -2026-07-30-static-repository-plugin-format.zh.md: 0b0a2af132137a820ba941869a612b7f28754fcb +2026-07-30-static-repository-plugin-format.md: f31728e28ddbb8e6403f327cb5b7c7533b214129 +2026-07-30-static-repository-plugin-format.zh.md: ec2295579353632a605aeb4eb2da7a39cfc4b23a diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md index c4739a6843..f31728e28d 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md @@ -14,7 +14,7 @@ The [package-manager-native repository cache](2026-07-30-package-manager-native- `@deepseek-ai/dsh-repository-plugin` owns a restricted `.dsh-plugin` package format with two contribution kinds only: skill roots and one common `.mcp.json`. Its package metadata uses `package.json#dsh.skills` for relative skill-root paths and `package.json#dsh.mcpServers` for the relative MCP document path. At least one is required. Each path may leave `.dsh-plugin` to reuse repository content but must remain beneath the directory containing that `.dsh-plugin`; a nested selectable Plugin therefore owns the adjacent subtree above its package without gaining access to unrelated host paths. -The `.dsh-plugin` package declares `dsh-plugin-prepare` as its ordinary package-manager `prepare` script. The helper validates metadata and source types, strictly parses `.mcp.json`, copies static assets into `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. The `.mjs` extension avoids imposing `type: module` on repository-authored package metadata. The generated module is a fixed import-free template containing only a normalized manifest, `inject = ['loader']`, and delegation to the `dsh-repository-plugin` Loader builtin. Preparation never discovers, transpiles, bundles, or preserves a custom repository entry point. +The `.dsh-plugin` package declares `dsh-plugin-prepare` as its ordinary package-manager `prepare` script. The helper validates metadata and source types, strictly parses `.mcp.json`, copies static assets into `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. The `.mjs` extension avoids imposing `type: module` on repository-authored package metadata. The generated module is a fixed import-free template containing only a normalized manifest, an `inject` list derived from it (`loader`, plus `skills` and/or `tools` per the declared capabilities, so the wrapper fiber gates on the services its children need), and delegation to the `dsh-repository-plugin` Loader builtin. Preparation never discovers, transpiles, bundles, or preserves a custom repository entry point. Loading the DSH package registers that builtin as an effect. A generated wrapper mounts the builtin as its child with `import.meta.url`, so all contributions belong to the wrapper fiber and disappear on Loader removal or rollback. The builtin revalidates the prepared manifest and path containment before reading assets. It composes the existing implementations rather than registering skills or MCP tools itself. diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md index 0b0a2af132..ec22955793 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md @@ -14,7 +14,7 @@ `@deepseek-ai/dsh-repository-plugin` 负责一个受限的 `.dsh-plugin` package 格式,且只允许两类贡献:skill 根和一个通用 `.mcp.json`。Package metadata 使用 `package.json#dsh.skills` 声明相对 skill 根路径,使用 `package.json#dsh.mcpServers` 声明相对 MCP 文档路径;两者至少需要一个。路径可以离开 `.dsh-plugin` 以复用仓库内容,但必须留在包含该 `.dsh-plugin` 的目录之下;因此,一个嵌套且可选择的 Plugin 可以拥有其 package 上方相邻的子树,却不能访问无关宿主路径。 -`.dsh-plugin` package 把 `dsh-plugin-prepare` 声明为普通 package-manager `prepare` 脚本。Helper 会校验 metadata 与源码类型,严格解析 `.mcp.json`,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。`.mjs` 扩展名避免强迫仓库作者在 package metadata 中设置 `type: module`。生成模块来自固定、无 import 的模板,只包含规范化 manifest、`inject = ['loader']`,以及对 `dsh-repository-plugin` Loader builtin 的委托。准备阶段永远不会发现、转译、打包或保留自定义仓库入口。 +`.dsh-plugin` package 把 `dsh-plugin-prepare` 声明为普通 package-manager `prepare` 脚本。Helper 会校验 metadata 与源码类型,严格解析 `.mcp.json`,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。`.mjs` 扩展名避免强迫仓库作者在 package metadata 中设置 `type: module`。生成模块来自固定、无 import 的模板,只包含规范化 manifest、由 manifest 派生的 `inject` 列表(`loader`,加上按声明能力加入的 `skills`/`tools`,使包装 fiber 在其子插件所需服务上门控),以及对 `dsh-repository-plugin` Loader builtin 的委托。准备阶段永远不会发现、转译、打包或保留自定义仓库入口。 加载 DSH package 会以 effect 方式注册该 builtin。生成的包装模块使用 `import.meta.url` 把 builtin 挂载为自己的子级,因此所有贡献都归属于包装 fiber,并在 Loader 移除或回滚时消失。Builtin 会在读取资源前重新校验已准备 manifest 与路径包含关系。它只组合现有实现,而不自行注册 skills 或 MCP 工具。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 457f1be7b8..14ce334f48 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -867,7 +867,7 @@ export interface StreamableHttpConfig { } ``` -Source: [`packages/mcp/mcp-client/src/index.ts:93`](../packages/mcp/mcp-client/src/index.ts) +Source: [`packages/mcp/mcp-client/src/index.ts:96`](../packages/mcp/mcp-client/src/index.ts) ## `@deepseek-ai/dsh-permission` @@ -1334,7 +1334,7 @@ export interface Config { watchMaxProjects?: number /** Whether watched symbolic links follow their target files. */ watchFollowSymlinks?: boolean - /** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR`, otherwise mounts none. */ + /** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR` when default roots are included, otherwise mounts none. */ bundledSkillDir?: string } ``` diff --git a/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs b/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs index 31225c0afc..5515aa82a2 100644 --- a/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs +++ b/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs @@ -1,9 +1,9 @@ // Generated by dsh-plugin-prepare. Do not edit. -const manifest = { "name": "headless-repository-fixture", "skills": ["dsh-plugin-assets/skills/0"] } -export const name = 'headless-repository-fixture' -export const inject = ['loader'] +const manifest = {"name":"headless-repository-fixture","skills":["dsh-plugin-assets/skills/0"]} +export const name = "headless-repository-fixture" +export const inject = ["loader","skills"] export async function apply(ctx) { - const runtime = ctx.loader.builtins['dsh-repository-plugin'] - if (runtime === undefined) throw new Error('missing Cordis builtin dsh-repository-plugin') + const runtime = ctx.loader.builtins["dsh-repository-plugin"] + if (runtime === undefined) throw new Error("missing Cordis builtin dsh-repository-plugin") await ctx.plugin(runtime, { baseUrl: import.meta.url, manifest }) } diff --git a/examples/headless-agent/tests/keyless-smoke.e2e.ts b/examples/headless-agent/tests/keyless-smoke.e2e.ts index d5e1cce826..4e18e177c3 100644 --- a/examples/headless-agent/tests/keyless-smoke.e2e.ts +++ b/examples/headless-agent/tests/keyless-smoke.e2e.ts @@ -1,10 +1,12 @@ -import { readFile, readdir } from 'node:fs/promises' +import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' import { zstdDecompress } from 'node:zlib' import { promisify } from 'node:util' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import { PREPARED_ENTRY_FILENAME, prepareDshPlugin } from '@deepseek-ai/dsh-repository-plugin' import type { SessionEvent } from '@deepseek-ai/dsh-session' const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) @@ -59,4 +61,28 @@ describe('headless-agent keyless smoke', () => { expect(String(result?.['result'])).toContain('CLI_TOOL_ROUND_TRIP') expect(persistedHeader).toMatchObject({ type: 'session' }) }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('keeps the checked-in prepared wrapper identical to the generator output for its manifest', async () => { + // The fixture claims "Generated by dsh-plugin-prepare"; this pin makes the + // claim true — a wrapper-template change fails here until the fixture is + // regenerated, so the assembled smoke can never exercise a stale shape. + const fixture = fileURLToPath(new URL('./fixtures/repository-plugin/', import.meta.url)) + const root = await mkdtemp(join(tmpdir(), 'dsh-fixture-drift-')) + try { + const plugin = join(root, '.dsh-plugin') + await mkdir(plugin, { recursive: true }) + await cp(join(fixture, 'dsh-plugin-assets/skills/0'), join(root, 'skills'), { recursive: true }) + await writeFile(join(plugin, 'package.json'), `${JSON.stringify({ + name: 'headless-repository-fixture', + version: '0.0.0', + dsh: { skills: ['../skills'] }, + }, undefined, 2)}\n`) + await prepareDshPlugin(plugin) + const generated = await readFile(join(plugin, PREPARED_ENTRY_FILENAME), 'utf8') + const checkedIn = await readFile(join(fixture, PREPARED_ENTRY_FILENAME), 'utf8') + expect(checkedIn).toBe(generated) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) }) diff --git a/packages/cordis/repository-plugin/README.i18n.yaml b/packages/cordis/repository-plugin/README.i18n.yaml index 806ee1c3af..52f506c37c 100644 --- a/packages/cordis/repository-plugin/README.i18n.yaml +++ b/packages/cordis/repository-plugin/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/cordis/repository-plugin/README.md -README.md: dab6287304f083e5c0ae128d5a3cb861332c076a -README.zh.md: 790e601ad022ffa20c7d02a88353e972bf8bffe2 +README.md: 80744eb489d1714f59ba6e53207476a8ce222e24 +README.zh.md: d297b44e4a065fa99865e3a42d2c823c7b7c5848 diff --git a/packages/cordis/repository-plugin/README.md b/packages/cordis/repository-plugin/README.md index dab6287304..80744eb489 100644 --- a/packages/cordis/repository-plugin/README.md +++ b/packages/cordis/repository-plugin/README.md @@ -36,7 +36,7 @@ The containing package manager still runs the configured repository package's li ## Runtime composition -Loading this package registers one effect-scoped Loader builtin. Each generated wrapper delegates to that builtin with its own module URL and prepared manifest. Repository skill roots mount as a uniquely named `dsh-skill-local` provider with default project/user roots excluded and watching disabled; cached package generations are immutable. Wrapper disposal removes the provider and all composed MCP clients through normal Cordis child-fiber teardown. +Loading this package registers one effect-scoped Loader builtin. Each generated wrapper delegates to that builtin with its own module URL and prepared manifest. The runtime validates every declared skill root as an existing in-package directory before mounting — a package whose generated outputs were dropped (a `files`/`.npmignore` mistake, a damaged cache entry) fails the plugin load instead of silently mounting a skill-less plugin. Repository skill roots mount as a uniquely named `dsh-skill-local` provider with default project/user roots excluded and watching disabled; cached package generations are immutable. Wrapper disposal removes the provider and all composed MCP clients through normal Cordis child-fiber teardown. ## Common MCP format diff --git a/packages/cordis/repository-plugin/README.zh.md b/packages/cordis/repository-plugin/README.zh.md index 790e601ad0..d297b44e4a 100644 --- a/packages/cordis/repository-plugin/README.zh.md +++ b/packages/cordis/repository-plugin/README.zh.md @@ -36,7 +36,7 @@ ## 运行时组合 -加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装模块都把自身模块 URL 和已准备的 manifest 委托给该 builtin。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存 package generation 是不可变的。包装模块 dispose 时,会通过正常的 Cordis 子 fiber teardown 移除提供方和所有组合的 MCP client。 +加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装模块都把自身模块 URL 和已准备的 manifest 委托给该 builtin。运行时在挂载前会校验每个声明的 skill 根都是包内实际存在的目录——生成输出被丢弃的包(`files`/`.npmignore` 配置失误、缓存条目损坏)会使插件加载失败,而不是静默挂载一个没有 skill 的插件。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存 package generation 是不可变的。包装模块 dispose 时,会通过正常的 Cordis 子 fiber teardown 移除提供方和所有组合的 MCP client。 ## 通用 MCP 格式 diff --git a/packages/cordis/repository-plugin/src/format.ts b/packages/cordis/repository-plugin/src/format.ts index 0c00142383..9d66321087 100644 --- a/packages/cordis/repository-plugin/src/format.ts +++ b/packages/cordis/repository-plugin/src/format.ts @@ -31,7 +31,10 @@ const preparedManifestSchema = z.object({ mcpServers: z.string().min(1).optional(), }).strict() const preparedConfigSchema = z.object({ - baseUrl: z.url(), + // Wrappers pass import.meta.url, which is always file: for an installed + // package; any other scheme would only fail later inside fileURLToPath with + // an uncontextualized TypeError, so reject it at this validation boundary. + baseUrl: z.url({ protocol: /^file$/ }), manifest: preparedManifestSchema, }).strict() @@ -70,7 +73,14 @@ export function parsePreparedPluginConfig(value: unknown): PreparedPluginConfig } } -function isOutside(root: string, candidate: string): boolean { +/** + * Whether `candidate` resolves outside `root` — the containment check shared + * by prepare-time asset copying and runtime prepared-path resolution. + * @param root - directory that must contain the candidate. + * @param candidate - absolute path to test. + * @returns true when the candidate escapes the root. + */ +export function isOutside(root: string, candidate: string): boolean { const path = relative(root, candidate) /* v8 ignore next -- Different-drive Windows relative paths cannot be produced on POSIX coverage hosts. */ return path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path) @@ -95,11 +105,22 @@ async function sourcePath(pluginDirectory: string, sourceRoot: string, configure } function wrapperSource(manifest: PreparedPluginManifest): string { + // The manifest is static, so the wrapper's service dependencies are too: + // declaring them gates the wrapper fiber until the composition provides + // them, which means the runtime's SkillLocal/McpClient children activate + // within the wrapper's own load epoch and their failures (duplicate + // provider names, damaged packages) reject the wrapper's Loader + // transaction instead of leaving a silently PENDING or FAILED child. + const inject = [ + 'loader', + ...manifest.skills.length > 0 ? ['skills'] : [], + ...manifest.mcpServers === undefined ? [] : ['tools'], + ] return [ '// Generated by dsh-plugin-prepare. Do not edit.', `const manifest = ${JSON.stringify(manifest)}`, `export const name = ${JSON.stringify(manifest.name)}`, - "export const inject = ['loader']", + `export const inject = ${JSON.stringify(inject)}`, 'export async function apply(ctx) {', ` const runtime = ctx.loader.builtins[${JSON.stringify(REPOSITORY_PLUGIN_BUILTIN)}]`, ` if (runtime === undefined) throw new Error(${JSON.stringify(`missing Cordis builtin ${REPOSITORY_PLUGIN_BUILTIN}`)})`, @@ -111,6 +132,10 @@ function wrapperSource(manifest: PreparedPluginManifest): string { /** * Validate and package one `.dsh-plugin` directory into static assets plus a fixed wrapper. + * Outputs are staged and committed by rename, but the final publish (remove + * old outputs, rename assets, rename entry) is not one atomic step: a crash + * mid-publish can leave assets without an entry or neither. Rerunning prepare + * repairs the package; partial outputs are never importable as a plugin. * @param directory - `.dsh-plugin` package directory; defaults to the prepare process cwd. * @returns the generated static manifest. */ diff --git a/packages/cordis/repository-plugin/src/index.ts b/packages/cordis/repository-plugin/src/index.ts index 50a76fc952..26922654bb 100644 --- a/packages/cordis/repository-plugin/src/index.ts +++ b/packages/cordis/repository-plugin/src/index.ts @@ -3,8 +3,8 @@ * @module @deepseek-ai/dsh-repository-plugin */ -import { readFile } from 'node:fs/promises' -import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' +import { readFile, stat } from 'node:fs/promises' +import { dirname, isAbsolute, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import type { Context } from 'cordis' import type {} from '@cordisjs/plugin-loader' @@ -12,6 +12,7 @@ import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import * as McpClient from '@deepseek-ai/dsh-mcp-client' import { REPOSITORY_PLUGIN_BUILTIN, + isOutside, parsePreparedPluginConfig, type PreparedPluginConfig, } from './format.ts' @@ -34,24 +35,42 @@ function preparedPath(baseUrl: string, configured: string): string { if (isAbsolute(configured)) throw new Error(`prepared DSH plugin path must be relative: ${JSON.stringify(configured)}`) const directory = dirname(fileURLToPath(baseUrl)) const path = resolve(directory, configured) - const rel = relative(directory, path) - /* v8 ignore next -- Different-drive Windows relative paths cannot be produced on POSIX coverage hosts. */ - if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + if (isOutside(directory, path)) { throw new Error(`prepared DSH plugin path escapes its package: ${JSON.stringify(configured)}`) } return path } +async function preparedDirectory(baseUrl: string, configured: string): Promise { + const path = preparedPath(baseUrl, configured) + // A manifest-declared skill root missing from the installed package (files/ + // .npmignore dropping generated outputs, a damaged cache entry) must fail + // the plugin load: the skill provider treats an absent root as legitimately + // empty, which would silently mount a skill-less plugin. + let info + try { + info = await stat(path) + } catch (cause) { + throw new Error(`prepared DSH plugin skill root is missing from the installed package: ${JSON.stringify(configured)}`, { cause }) + } + if (!info.isDirectory()) { + throw new Error(`prepared DSH plugin skill root is not a directory: ${JSON.stringify(configured)}`) + } + return path +} + async function applyPrepared(ctx: Context, value: PreparedPluginConfig): Promise { const config = parsePreparedPluginConfig(value) const directory = dirname(fileURLToPath(config.baseUrl)) - const skillDirectories = config.manifest.skills.map(path => preparedPath(config.baseUrl, path)) + const skillDirectories = await Promise.all(config.manifest.skills.map(path => preparedDirectory(config.baseUrl, path))) const mcpConfigs = config.manifest.mcpServers === undefined ? [] : resolveMcpServers( parseMcpDocument(await readFile(preparedPath(config.baseUrl, config.manifest.mcpServers), 'utf8')), process.env, directory, + // Schemastery call signatures collapse the parameter to `never` under + // NodeNext; ResolvedMcpServer is shaped for the Config union by design. ).map(input => McpClient.Config(input as never)) await ctx.effect(async function* () { diff --git a/packages/cordis/repository-plugin/src/mcp.ts b/packages/cordis/repository-plugin/src/mcp.ts index 893d96f086..bce8179121 100644 --- a/packages/cordis/repository-plugin/src/mcp.ts +++ b/packages/cordis/repository-plugin/src/mcp.ts @@ -5,7 +5,14 @@ import { z } from 'zod' -const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/ +/** + * Restates dsh-mcp-client's `SERVER_NAME_PATTERN` rather than importing it: + * the prepare bin must stay a zod-only module graph (no tools seam, no MCP + * SDK). Exported so `repository-plugin.spec.ts` pins equality with the + * client's exported pattern — prepare-time validation cannot drift from the + * registry that enforces uniqueness. + */ +export const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/ const ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ const PLACEHOLDER_PATTERN = /\$\{([^}]*)\}/g @@ -89,7 +96,7 @@ export function parseMcpDocument(content: string): McpDocument { if (!result.success) throw new Error(`invalid .mcp.json:\n${z.prettifyError(result.error)}`) for (const [serverName, definition] of Object.entries(result.data.mcpServers)) { if (!SERVER_NAME_PATTERN.test(serverName)) { - throw new Error(`invalid .mcp.json: server name ${JSON.stringify(serverName)} must match [A-Za-z0-9_-]{1,32}`) + throw new Error(`invalid .mcp.json: server name ${JSON.stringify(serverName)} must match ${SERVER_NAME_PATTERN.source}`) } visitStrings(serverName, definition, assertTemplate) } diff --git a/packages/cordis/repository-plugin/tests/mcp-format.spec.ts b/packages/cordis/repository-plugin/tests/mcp-format.spec.ts index 094cb93020..5251ccdbc2 100644 --- a/packages/cordis/repository-plugin/tests/mcp-format.spec.ts +++ b/packages/cordis/repository-plugin/tests/mcp-format.spec.ts @@ -1,7 +1,15 @@ import { describe, expect, it } from 'vitest' -import { parseMcpDocument, resolveMcpServers } from '../src/mcp.ts' +import { SERVER_NAME_PATTERN as CLIENT_SERVER_NAME_PATTERN } from '@deepseek-ai/dsh-mcp-client' +import { SERVER_NAME_PATTERN, parseMcpDocument, resolveMcpServers } from '../src/mcp.ts' describe('repository plugin common .mcp.json support', () => { + it('validates server names with exactly the pattern the MCP client registry enforces', () => { + // mcp.ts restates the pattern to keep the prepare bin's module graph + // zod-only; this pin is the drift guard. + expect(SERVER_NAME_PATTERN.source).toBe(CLIENT_SERVER_NAME_PATTERN.source) + expect(SERVER_NAME_PATTERN.flags).toBe(CLIENT_SERVER_NAME_PATTERN.flags) + }) + it('maps Expo-style HTTP servers to the existing Streamable HTTP client config', () => { const document = parseMcpDocument(JSON.stringify({ mcpServers: { diff --git a/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts b/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts index f8e5807501..e26f267e72 100644 --- a/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts +++ b/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts @@ -59,7 +59,9 @@ describe('dsh-plugin-prepare', () => { }) const wrapper = await readFile(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME), 'utf8') expect(wrapper).toContain(`ctx.loader.builtins["${RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN}"]`) - expect(wrapper).not.toMatch(/\b(?:import|from)\s/) + // Import-free means no static AND no dynamic imports; `import.meta.url` + // (no whitespace, no call parenthesis) is the one allowed appearance. + expect(wrapper).not.toMatch(/\b(?:import|from)\s|\bimport\s*\(/) await expect(readFile(join(directory, 'dsh-plugin-assets/skills/0/repository-fixture/SKILL.md'), 'utf8')) .resolves.toContain('Static instructions.') await expect(readFile(join(directory, 'dsh-plugin-assets/.mcp.json'), 'utf8')) @@ -218,6 +220,34 @@ describe('prepared repository plugin Loader composition', () => { await ctx.fiber.dispose() }) + it('fails the plugin load when a declared skill root is missing or not a directory', async () => { + const root = await temporaryDirectory('missing-skill-root') + await writeFile(join(root, 'not-a-directory'), 'text') + const ctx = new Context() + ctx.baseUrl = pathToFileURL(root).href + '/' + await ctx.plugin(Loader) + await ctx.plugin(SkillService) + await ctx.plugin(RepositoryPlugin) + + for (const [filename, skillPath, message] of [ + ['missing.mjs', 'dsh-plugin-assets/skills/0', 'skill root is missing from the installed package'], + ['file.mjs', 'not-a-directory', 'skill root is not a directory'], + ] as const) { + const wrapper = join(root, filename) + await writeFile(wrapper, [ + "export const inject = ['loader']", + 'export async function apply(ctx) {', + ` await ctx.plugin(ctx.loader.builtins['${RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN}'], {`, + ` baseUrl: import.meta.url, manifest: { name: 'damaged', skills: [${JSON.stringify(skillPath)}] },`, + ' })', + '}', + '', + ].join('\n')) + await expect(ctx.loader.create({ name: pathToFileURL(wrapper).href })).rejects.toThrow(message) + } + await ctx.fiber.dispose() + }) + it('rejects duplicate builtin ownership and preserves a later replacement on teardown', async () => { const ctx = new Context() await ctx.plugin(Loader) diff --git a/packages/mcp/mcp-client/src/index.ts b/packages/mcp/mcp-client/src/index.ts index 1eec41b96e..49609bac9a 100644 --- a/packages/mcp/mcp-client/src/index.ts +++ b/packages/mcp/mcp-client/src/index.ts @@ -36,8 +36,11 @@ const DEFAULT_TOOL_CALL_TIMEOUT_MS = 60_000 /** * Valid `serverName`: 1–32 chars of `[A-Za-z0-9_-]`. Kept well under the * 64-char public-name budget so typical raw tool names survive unhashed. + * Exported so upstream producers of Config inputs (repository-plugin's + * `.mcp.json` prepare-time validation) reject the same names this registry + * would. */ -const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/ +export const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/ /** * Live `serverName` reservations per app, keyed off `ctx.root` (multiple apps diff --git a/packages/skill/skill-local/README.i18n.yaml b/packages/skill/skill-local/README.i18n.yaml index 1902122c68..269054f9f8 100644 --- a/packages/skill/skill-local/README.i18n.yaml +++ b/packages/skill/skill-local/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/skill/skill-local/README.md -README.md: 836a2a631e9e6e452a11e3cffc102de355f1c5d9 -README.zh.md: 2e2cc45ad80f760e04f813b7ee85932b51b1df05 +README.md: f85cc2e6fd0c32cb88f28a2914a03e22b3a20657 +README.zh.md: 73a66831ad14b7edb346227cf6adb52ec8247fd7 diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index 836a2a631e..f85cc2e6fd 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -38,7 +38,7 @@ Default roots are resolved in this provider's rank order: | 400 | `user-dsh` | `/skills` | | 500 | `user-agents` | `/skills` | -The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not treated as normal user skills. `includeDefaultRoots: false` omits both project and user rows while retaining explicitly configured custom and bundled roots, allowing several uniquely named isolated providers such as immutable repository Plugins. This provider supplies project and user skills; another provider may supply built-in system skills. +The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not treated as normal user skills. `includeDefaultRoots: false` omits the project and user rows and the `$DSH_BUNDLED_SKILL_DIR` environment default while retaining explicitly configured custom and bundled roots, allowing several uniquely named isolated providers such as immutable repository Plugins to see only their own roots. This provider supplies project and user skills; another provider may supply built-in system skills. When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and probes `.git` through the filesystem service. Full skill loads forward the lookup abort signal to filesystem metadata and content reads. Without a filesystem service, the provider falls back to abortable Node filesystem I/O so minimal local contexts can still load skills. Confirmed missing paths are valid empty state, malformed or non-text entries warn and skip, and unexpected discovery/read failures make the registry snapshot incomplete rather than replacing a last-good model catalog with a misleading deletion. diff --git a/packages/skill/skill-local/README.zh.md b/packages/skill/skill-local/README.zh.md index 2e2cc45ad8..73a66831ad 100644 --- a/packages/skill/skill-local/README.zh.md +++ b/packages/skill/skill-local/README.zh.md @@ -38,7 +38,7 @@ | 400 | `user-dsh` | `/skills` | | 500 | `user-agents` | `/skills` | -项目根目录是包含 `.git` 的最近祖先目录;如果不存在,则使用当前 cwd。用户 DSH 根目录会跳过其 `.system` 子目录,因此归系统所有的目录不会被当作普通用户 skill。`includeDefaultRoots: false` 会省略项目和用户两类根,同时保留显式配置的自定义根与 bundled 根,因此可以挂载多个唯一命名的隔离提供方,例如不可变 repository Plugin。该提供方提供项目和用户 skill;其他提供方可提供内置系统 skill。 +项目根目录是包含 `.git` 的最近祖先目录;如果不存在,则使用当前 cwd。用户 DSH 根目录会跳过其 `.system` 子目录,因此归系统所有的目录不会被当作普通用户 skill。`includeDefaultRoots: false` 会省略项目根、用户根以及 `$DSH_BUNDLED_SKILL_DIR` 环境默认值,同时保留显式配置的自定义根与 bundled 根,因此可以挂载多个只看到自身根的唯一命名隔离提供方,例如不可变 repository Plugin。该提供方提供项目和用户 skill;其他提供方可提供内置系统 skill。 当 `ctx.fs` 可用时,发现通过 `ctx.fs.listDir` 列出根,通过 `ctx.fs.readText` 读取 skill 文件,并通过文件系统服务探测 `.git`。完整 skill 加载会将查找中止信号转发给文件系统元数据和内容读取。如果没有文件系统服务,提供方回退到可中止的 Node 文件系统 I/O,使最小本地上下文仍能加载 skill。已确认缺失的路径属于有效空状态;格式错误或非文本条目会警告并跳过;意外的发现或读取失败会使注册表快照不完整,系统不会因此用看似发生删除的结果替换上一份可用模型目录。 diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index aa07443a56..d6df41237e 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -69,7 +69,7 @@ export interface Config { watchMaxProjects?: number /** Whether watched symbolic links follow their target files. */ watchFollowSymlinks?: boolean - /** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR`, otherwise mounts none. */ + /** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR` when default roots are included, otherwise mounts none. */ bundledSkillDir?: string } @@ -165,7 +165,12 @@ export class LocalSkillProvider implements SkillProvider { this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root)) this.watchManager = new SkillWatchManager(ctx, control.invalidate, resolveWatchConfig(config)) control.signal.addEventListener('abort', () => { void this.dispose() }, { once: true }) - const bundledSkillDir = config.bundledSkillDir ?? process.env.DSH_BUNDLED_SKILL_DIR + // The environment bundled root is a default root: an isolated provider + // (includeDefaultRoots: false — repository plugins) must see only its + // explicit custom roots, or every such provider would re-discover the + // app's bundled skills and claim them under its own provider name. + const bundledSkillDir = config.bundledSkillDir + ?? (this.includeDefaultRoots ? process.env.DSH_BUNDLED_SKILL_DIR : undefined) this.bundledSkillDir = bundledSkillDir === undefined ? undefined : resolve(bundledSkillDir) } diff --git a/packages/skill/skill-local/tests/skill-local.spec.ts b/packages/skill/skill-local/tests/skill-local.spec.ts index ae57df3bca..0f92cac1d6 100644 --- a/packages/skill/skill-local/tests/skill-local.spec.ts +++ b/packages/skill/skill-local/tests/skill-local.spec.ts @@ -820,6 +820,22 @@ describe('LocalSkillProvider', () => { await ctx.plugin(SkillLocal, { watch: false }) expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-bundled-skill', 'env-skill']) + // Isolated providers see only their explicit roots: the environment + // bundled root is a default root, so includeDefaultRoots: false must + // drop it — repository providers never re-claim the app's builtins. + const isolated = new Context() + await isolated.plugin(SkillService) + const customOnly = join(envHome, 'custom-only') + await writeSkill(customOnly, 'custom-isolated-skill', 'Custom isolated skill') + await isolated.plugin(SkillLocal, { + providerName: 'isolated', + includeDefaultRoots: false, + customSkillDirs: [customOnly], + watch: false, + }) + expect((await isolated.skills.list()).map(skill => skill.name)).toEqual(['custom-isolated-skill']) + await isolated.fiber.dispose() + process.env.DSH_HOME = join(envHome, 'empty-dsh') delete process.env.DSH_BUNDLED_SKILL_DIR process.env.DSH_AGENTS_HOME = join(envHome, 'empty-agents') From 24484968037aa3ee00c402a72c6b942effb2cb0c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 06:00:53 +0800 Subject: [PATCH 19/23] feat: configure repository plugins from DSH home --- ...-static-repository-plugin-format.i18n.yaml | 4 +- ...6-07-30-static-repository-plugin-format.md | 2 +- ...7-30-static-repository-plugin-format.zh.md | 2 +- ...26-07-20-dsh-cli-personal-config.i18n.yaml | 4 +- .../2026-07-20-dsh-cli-personal-config.md | 16 +-- .../2026-07-20-dsh-cli-personal-config.zh.md | 16 +-- ...0-config-only-repository-plugins.i18n.yaml | 6 + ...26-07-30-config-only-repository-plugins.md | 50 ++++++++ ...07-30-config-only-repository-plugins.zh.md | 50 ++++++++ apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 14 ++- apps/cli/README.zh.md | 14 ++- apps/cli/composition.md | 3 + apps/cli/config/base.cordis.yml | 7 ++ apps/cli/package.json | 1 + apps/cli/src/app-cli-entry.ts | 38 ++++-- apps/cli/src/headless.ts | 1 + apps/cli/src/tui.ts | 15 ++- apps/cli/src/web.ts | 1 + apps/cli/tests/tui-keyless-smoke.e2e.ts | 56 +++++++++ docs/config-catalog.md | 17 ++- docs/module-graph.md | 3 +- knip.json | 6 +- .../cordis/repository-plugin/README.i18n.yaml | 4 +- packages/cordis/repository-plugin/README.md | 17 +++ .../cordis/repository-plugin/README.zh.md | 17 +++ .../cordis/repository-plugin/package.json | 2 + .../cordis/repository-plugin/src/index.ts | 35 +++++- .../cordis/repository-plugin/src/source.ts | 74 +++++++++++ .../tests/repository-plugin.spec.ts | 119 +++++++++++++++++- .../cordis/repository-plugin/tsconfig.json | 3 + packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 6 +- packages/ui/app-boot/README.zh.md | 6 +- packages/ui/app-boot/package.json | 6 + packages/ui/app-boot/src/index.ts | 99 ++++++++++++--- .../ui/app-boot/tests/personal-config.spec.ts | 101 ++++++++++++++- .../app-boot/tests/repository-cache.spec.ts | 27 +++- packages/ui/app-boot/tsconfig.json | 3 + pnpm-lock.yaml | 6 + 40 files changed, 783 insertions(+), 76 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md create mode 100644 .agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.zh.md create mode 100644 packages/cordis/repository-plugin/src/source.ts diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml index b3fbb54f35..6319b99fb2 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.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-30-static-repository-plugin-format.md -2026-07-30-static-repository-plugin-format.md: f31728e28ddbb8e6403f327cb5b7c7533b214129 -2026-07-30-static-repository-plugin-format.zh.md: ec2295579353632a605aeb4eb2da7a39cfc4b23a +2026-07-30-static-repository-plugin-format.md: c9d755b925a6ea05eed71e75803397d2672df9f4 +2026-07-30-static-repository-plugin-format.zh.md: 361de64d2e98b9fb4ac42963e4ae48e77fbc7016 diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md index f31728e28d..c9d755b925 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md @@ -22,7 +22,7 @@ Each prepared skill set mounts `dsh-skill-local` with a unique `repository: 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 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. +- `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. The [repository Plugin integration](2026-07-30-config-only-repository-plugins.md) uses one shipped row to make an exact GitHub source list a config-only choice. - 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. -Hot-reload interplay: the include re-applies its `patches` on every config re-read (the [config hot-reload resilience Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md)), so a live `cordis.yml` edit keeps the personal overlay applied. +The TUI and Web register the exact personal path through Cordis HMR after boot. Every add, change, or removal transactionally recomposes the full patch list through the launcher's own composition closure, so the fresh personal patches land in the same layer position they booted in. Invalid YAML or a rejected Loader candidate leaves the last good tree active and broadcasts `hmr/config-update-failed(filename, Error)`; the headless surface reads the file once at startup. The Include also re-applies its patches on committed config-file refreshes (the [config hot-reload resilience Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md)). ## Alternatives considered -**A standalone `bin/dsh` wrapper owning the `dsh` name.** Rejected after reading PR #443: that PR establishes `apps/cli` as the `dsh` CLI with subcommand dispatch (`web`, `-p`) and leaves the default slot unclaimed. Two competing `dsh` entrypoints would collide in `$PATH` and in product identity; claiming the default slot inside the same package shape confines the eventual merge conflict to the small dispatch chain. +**A separate `bin/dsh` wrapper owning the `dsh` name.** Rejected because `apps/cli` is the single product CLI for default TUI, headless, and Web dispatch. Two competing entrypoints would collide in `$PATH` and product identity. **A pi-style typed settings file (`defaultProvider`/`defaultModel`/`providers`).** Rejected by the user in favor of patch semantics: the personal file is a cordis overlay over the shipped default config, not a second config vocabulary to own and translate. @@ -38,12 +38,12 @@ Hot-reload interplay: the include re-applies its `patches` on every config re-re ## Consequences -- `dsh` from any directory (and `pnpm run demo:tui`) boots the personal provider/model with zero repo changes; verified end-to-end against a personal Anthropic proxy with Opus 4.8, including a bash tool round trip. +- `dsh` from any directory (and `pnpm run demo:tui`) can apply personal providers, models, repository Plugins, and other Loader entries with no checkout edit; verified end-to-end against a personal Anthropic proxy with Opus 4.8, including a bash tool round trip. - Because an id-targeted patch replaces the whole `config`, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings and [`dsh --dump-config`](2026-07-30-dsh-dump-config.md) (which prints the composed tree those patches produce) are the diagnostics. - Personal patches resolve ids against the booted file's own tree, so nested-include overlays (Code Mode) are not personalized; live-run parity for those leaves is deferred. - `dsh-app-boot` depends on `js-yaml` and imports the include's `!!js` YAML dialect (`entryListSchema`) directly, and, like `apps/cli`, depends on `@deepseek-ai/dsh-paths` for `resolveDshHome`. -- When PR #443 lands, `apps/cli/src/bin.ts`'s dispatch chain and `apps/cli/package.json`'s dependency list conflict textually; both resolve as unions (their `web`/`-p` branches plus our default-TUI branch). +- Live watching belongs only to long-running TUI and Web processes. Headless automation gets deterministic startup configuration and exits without retaining a watcher. ## Testing -`packages/ui/app-boot/tests/personal-config.spec.ts` pins `!!js` preservation and end-to-end interpolation through a booted tree, insert entries, the default directory resolving from `$DSH_HOME`, the absent/empty no-op paths, and the three fail-loud shapes (unreadable, unparsable, non-array). `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the dsh bin in a PTY three ways: default config with no overlay, a personal `.env` + `config.yaml` chain whose patched welcome renders in the banner, and an invalid personal file failing the boot loudly. The pre-existing smokes and snapshot suites pass on a machine whose real `~/.dsh` overlay would change the booted model — the isolation, not luck. +`packages/ui/app-boot/tests/personal-config.spec.ts` pins parsing, startup application, exact-path add/failure/recovery/removal, last-good rollback, failure broadcast, and preservation of app-owned patches. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the real dsh bin with no overlay, a personal environment and UI patch, a config-only cached repository skill, and invalid personal YAML. Test launchers isolate `$DSH_HOME`, so a developer's real overlay cannot leak into fixtures. 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 8f7c15c3c6..e70b8914cf 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 @@ -12,21 +12,21 @@ Status: implemented 两个耦合的部分,与 `dsh web` PR(#443)提出的 `apps/` 装配层对齐: -**`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` CLI(`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 是位于 `packages/*` 库之上的产品组装层。一个 bin 负责分发默认交互式 TUI、`-p`/`--prompt` 无头轮次和 `web` 界面。TUI 以调用目录为 workspace,启动 `examples/tui-agent/cordis.yml`(或 `--config` 指定的配置)。已提交的 `bin/dsh` 启动器通过自身真实路径解析 checkout,并使用 tsx 的 ESM hook 运行应用;该契约由[源码启动决策](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md)维护。`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 的官方界面消费其中两个可选文件;各示例 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 的 TUI、Web 和无头界面使用其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动: - `.env`——在调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境变量 > 项目 `.env` > 个人 `.env`。 -- `config.yaml`——顶层 YAML 数组,元素为 `@cordisjs/plugin-include` 的 `PatchOptions`,用 include 自己的 `!!js` 方言解析(`loadPersonalPatches`)并传给 `boot()`,由它作为根 include 的 `patches` 转发。补丁语义与交付的 surface overlay 一致:按 id 定位的补丁替换该配置项的整个 `config`,`insert` 追加配置项,未匹配的 id 静默不执行任何操作。 +- `config.yaml`——顶层 YAML 数组,元素为 `@cordisjs/plugin-include` 的 `PatchOptions`,用 include 自己的 `!!js` 方言解析(`loadPersonalPatches`)并传给 `boot()`,由它作为根 include 的 `patches` 转发。补丁语义与交付的 surface overlay 一致:按 id 定位的补丁替换该配置项的整个 `config`,`insert` 追加配置项,未匹配的 id 静默不执行任何操作。[仓库插件集成](2026-07-30-config-only-repository-plugins.md)通过一个已交付配置项,使精确 GitHub 源列表成为纯配置选择。 - 文件缺失即无 overlay;文件存在但不可读、不可解析或非数组则在启动时抛出(配置错误响亮失败,绝不静默跳过)。 PTY 冒烟测试的启动器把 `$DSH_HOME` 隔离到每个测试自己的目录,与它已有的 `DSH_AGENTS_HOME` 隔离方式完全一致,开发者真实的个人 overlay 不可能泄漏进 fixture;只有 dsh CLI 读取个人配置,因此其他测试启动器无需改动。 -与热重载的交互:include 在每次配置重读时重新应用其 `patches`(见[配置热重载韧性 Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md)),因此运行中编辑 `cordis.yml` 后个人 overlay 仍保持生效。 +TUI 和 Web 启动后通过 Cordis HMR(热模块替换)注册确切的个人配置路径。每次新增、变更或移除都会以事务方式通过启动器自己的组合闭包重新组合完整 patch 列表,因此新的个人 patch 落在启动时相同的层次位置。YAML 无效或 Loader 候选被拒时,最后一个可用树保持活动状态,并广播 `hmr/config-update-failed(filename, Error)`;无头界面只在启动时读取该文件。Include 在已提交配置文件刷新时也会重新应用其 patch(见[配置热重载韧性 Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md))。 ## Alternatives considered -**独立的 `bin/dsh` 包装脚本占有 `dsh` 这个名字。** 读过 PR #443 后否决:该 PR 把 `apps/cli` 确立为带子命令分发(`web`、`-p`)的 `dsh` CLI,并且默认位空缺。两个互相竞争的 `dsh` 入口会在 `$PATH` 和产品身份上冲突;在同一包形态内认领默认位,把最终的合并冲突限制在小小的分发链上。 +**另设一个 `bin/dsh` 包装脚本并由其占用 `dsh` 名称。** 否决,因为 `apps/cli` 是统一的产品 CLI,负责分发默认 TUI、无头和 Web 界面。两个相互竞争的入口会在 `$PATH` 和产品身份上冲突。 **pi 风格的类型化设置文件(`defaultProvider`/`defaultModel`/`providers`)。** 用户否决,选择补丁语义:个人文件是叠加在随仓库提供的默认配置之上的 cordis overlay,而不是需要另行拥有和翻译的第二套配置词汇。 @@ -38,12 +38,12 @@ PTY 冒烟测试的启动器把 `$DSH_HOME` 隔离到每个测试自己的目录 ## Consequences -- 在任意目录运行 `dsh`(以及 `pnpm run demo:tui`)即可零仓库改动地使用个人提供方/模型;已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。 +- 在任意目录运行 `dsh`(以及 `pnpm run demo:tui`),无需修改 checkout,即可应用个人提供方、模型、仓库插件和其他 Loader 配置项;已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。 - 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;诊断手段是 loader 的「配置项未找到/名称不匹配」警告和 [`dsh --dump-config`](2026-07-30-dsh-dump-config.md)(打印这些补丁合成出的配置树)。 - 个人补丁只在被启动文件自身的树里解析 id,因此嵌套 include 的 overlay(Code Mode)不会被个性化;这些叶子的实际运行等价性暂缓。 - `dsh-app-boot` 依赖 `js-yaml`,并直接导入 include 的 `!!js` YAML 方言(`entryListSchema`);与 `apps/cli` 一样依赖 `@deepseek-ai/dsh-paths` 以获取 `resolveDshHome`。 -- PR #443 落地时,`apps/cli/src/bin.ts` 的分发链与 `apps/cli/package.json` 的依赖列表会产生文本冲突;两者都按并集解决(他们的 `web`/`-p` 分支加上我们的默认 TUI 分支)。 +- 只有长时间运行的 TUI 和 Web 进程进行实时监视。无头自动化使用确定性的启动配置,退出时不会保留 watcher。 ## Testing -`packages/ui/app-boot/tests/personal-config.spec.ts` 固定 `!!js` 的保留与经真实启动树的端到端插值、insert 配置项、默认目录从 `$DSH_HOME` 解析、缺失/为空的无操作路径,以及三种响亮失败形态(不可读、不可解析、非数组)。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 在 PTY 里以三种方式启动 dsh bin:无 overlay 的默认配置、个人 `.env` + `config.yaml` 链条(打补丁的欢迎语渲染进横幅)、以及无效个人文件导致的响亮启动失败。既有冒烟与快照套件在一台真实 `~/.dsh` overlay 会改变启动模型的机器上通过——靠隔离,不靠运气。 +`packages/ui/app-boot/tests/personal-config.spec.ts` 固定解析、启动时应用、确切路径的新增/失败/恢复/移除、最后可用状态回滚、失败广播以及应用自有 patch 的保留。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 启动真实 dsh bin,覆盖无 overlay、个人环境与 UI patch、纯配置的缓存 repository skill,以及无效个人 YAML。测试启动器会隔离 `$DSH_HOME`,因此开发者的真实 overlay 不会泄漏进 fixture。 diff --git a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.i18n.yaml new file mode 100644 index 0000000000..1491968f15 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.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-30-config-only-repository-plugins.md +2026-07-30-config-only-repository-plugins.md: 2057125fc78596dd4e5eb153f77b828f83d9ceff +2026-07-30-config-only-repository-plugins.zh.md: 6e741b46be716e21a11c2f508fb5e6d0505c76d0 diff --git a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md new file mode 100644 index 0000000000..2057125fc7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md @@ -0,0 +1,50 @@ +# Agent Note: Config-only repository Plugins for standalone dsh + +Status: implemented + +English | [中文](2026-07-30-config-only-repository-plugins.zh.md) + +## Problem + +A standalone `dsh` user has no developer-owned SDK project whose `package.json`, lockfile, and `cordis.yml` can carry an external Plugin dependency. Requiring an install command or another state file would make “use this repository” a multi-step workflow, while loading arbitrary repository code would bypass the restricted [static repository Plugin format](../architecture/2026-07-30-static-repository-plugin-format.md). Long-running TUI and Web processes also need a failed edit to preserve their usable Plugin generation and tell observers why the candidate was rejected. + +## Decision + +The shipped TUI and Web/headless `cordis.yml` trees contain an empty `repository-plugins` entry. A user changes only `$DSH_HOME/config.yaml`, replacing that entry's config with a `repositories` list. Each item uses `github:owner/repository#` plus an optional `&path:/.../.dsh-plugin`; omission selects `/.dsh-plugin`. An explicit ref is mandatory, paths are absolute within the repository and end in `.dsh-plugin`, and duplicate normalized specifiers reject before installation. There is no marketplace, discovery index, HTTPS URL vocabulary, or implicit latest generation. + +`@deepseek-ai/dsh-repository-plugin` validates and normalizes each source, then resolves it through the generic vendored [`RepositoryCache`](../architecture/2026-07-30-package-manager-native-repository-cache.md). The default cache is `$DSH_HOME/cache/repository-plugins`; `cacheDir` is the explicit deployment override. Bundled pnpm selects the configured repository subpackage, runs its ordinary lifecycle including `prepare`, and atomically publishes the exact specifier. The DSH host imports only the generated `dsh-plugin.mjs` wrapper and mounts it as a child fiber, so skills and MCP retain the owners, failure contracts, and teardown defined by the format package. + +## Live update and failure + +`dsh-app-boot` mounts the root Include through one helper that retains its exact Loader `Entry`. The TUI and Web register `$DSH_HOME/config.yaml` through Cordis HMR; headless reads the same file at startup without retaining a watcher. A watcher update rebuilds the Include patch list as immutable app-owned patches followed by the newly parsed personal patches, so Web-generated port, session-root, trust, and frontend values survive every personal edit unless a later personal patch deliberately replaces that row. + +Cordis serializes and coalesces exact-path changes. Include and Loader reconcile a candidate transactionally: success commits the new source list, while fetch, preparation, wrapper import, format, or child-Plugin failure rejects the candidate and retains or restores the last good tree. HMR normalizes the caught value to `Error`, logs it, and broadcasts the parallel `hmr/config-update-failed(filename, error)` event; observer failures cannot break refresh processing. MCP transport connection failure remains the existing MCP client's contained successful-Plugin/no-tools result and therefore is not reclassified as a config-update failure. + +An identical specifier permanently reuses its cache generation. HMR watches configuration, not cached repository code; the user changes the ref, path, or source list to select another generation. + +## Trust boundary + +Configuring a repository authorizes package-manager lifecycle code from that repository and its dependencies to run with the user's filesystem authority. The pnpm child removes ambient environment variables whose names contain `KEY`, `PASSWORD`, `SECRET`, or `TOKEN`, but this is credential-exposure reduction rather than a sandbox. The fixed runtime wrapper prevents repository-authored Cordis entry points from becoming part of the supported Plugin format; it does not make package preparation untrusted-safe. + +## Alternatives considered + +**Require an SDK project dependency.** Rejected for the standalone app path because there is no project manifest to edit. Developer-owned SDK projects keep their native package-manager workflow as a separate capability. + +**Add a `dsh plugin install` command and installation database.** Rejected because the personal Loader overlay already owns machine-local composition. A second mutation interface and durable registry would duplicate config identity and rollback. + +**Resolve repositories directly in the DSH package.** Rejected because Git transport, GitHub subpackage selection, lifecycle execution, and content storage belong to pnpm and the generic Loader cache, not a DSH-specific adapter. + +**Watch cache contents or refresh the same ref automatically.** Rejected because one config value must identify one immutable prepared generation. Background remote resolution would change executable code without a config diff and make rollback depend on mutable remote state. + +**Broadcast an `unknown` failure payload.** Rejected at the HMR boundary. JavaScript may throw any value internally, but the public event always receives a normalized `Error`, giving observers one stable contract while retaining the original value as its cause when needed. + +## Consequences + +- A repository that adds `.dsh-plugin/package.json` can reach standalone users through one personal-config edit without changing its existing skills or `.mcp.json` layout. +- Long-running apps can add, replace, or remove configured generations without restart; rejected candidates retain the last good runtime and produce one generic Cordis event. +- First use may require Git/network access and preparation time. Later starts reuse the exact prepared cache; old generations consume disk until a separate cache-management policy exists. +- Only skills and common MCP definitions are supported. Hooks, commands, agents, apps, arbitrary Cordis code, compatibility shims, OAuth-bearing MCP definitions, and marketplaces remain intentionally absent. + +## Testing + +Repository-package tests pin source normalization, default and nested `.dsh-plugin` paths, cache-root resolution, duplicate rejection, prepared-wrapper loading, and disposal. App-boot tests drive exact-path add, two failure classes, recovery, removal, failure events, and generated-patch preservation through the real HMR/Include/Loader path. A keyless PTY smoke boots the shipped `dsh` composition from personal config alone and invokes a skill from a seeded immutable cache generation. diff --git a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.zh.md b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.zh.md new file mode 100644 index 0000000000..6e741b46be --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.zh.md @@ -0,0 +1,50 @@ +# Agent Note: 仅凭配置为独立 dsh 接入仓库插件 + +Status: implemented + +[English](2026-07-30-config-only-repository-plugins.md) | 中文 + +## 问题 + +独立 `dsh` 用户没有开发者自有的 SDK 项目,无法由其 `package.json`、lockfile 和 `cordis.yml` 承载外部插件依赖。若要求运行安装命令或维护另一份状态文件,「使用这个仓库」就会变成多步骤流程;若加载任意仓库代码,又会绕过受限的[静态仓库插件格式](../architecture/2026-07-30-static-repository-plugin-format.md)。长时间运行的 TUI 和 Web 进程还必须在编辑失败时保留仍可使用的插件版本,并向观察者说明候选配置被拒绝的原因。 + +## 决策 + +已交付的 TUI 和 Web/无头 `cordis.yml` 配置树包含一个空的 `repository-plugins` 配置项。用户只需修改 `$DSH_HOME/config.yaml`,用 `repositories` 列表替换该配置项的配置。每一项采用 `github:owner/repository#`,并可追加 `&path:/.../.dsh-plugin`;省略时选择 `/.dsh-plugin`。必须显式指定 ref;路径是仓库内的绝对路径,并以 `.dsh-plugin` 结尾;重复的规范化说明符在安装前即被拒绝。不提供插件市场、发现索引、HTTPS URL 词汇或隐式的最新版本。 + +`@deepseek-ai/dsh-repository-plugin` 校验并规范化每个源,再通过 vendor 中的通用 [`RepositoryCache`](../architecture/2026-07-30-package-manager-native-repository-cache.md) 解析。默认缓存位于 `$DSH_HOME/cache/repository-plugins`;`cacheDir` 是显式的部署覆盖项。随应用提供的 pnpm 选择配置的仓库子包(package),运行包括 `prepare` 在内的普通生命周期,并原子发布该精确说明符。DSH 宿主只导入生成的 `dsh-plugin.mjs` 包装模块并将其挂载为子 fiber,因此 skill(技能)与 MCP 仍沿用格式包定义的所有者、失败契约和清理行为。 + +## 实时更新与失败 + +`dsh-app-boot` 通过一个辅助函数挂载根 Include,并保留其确切的 Loader `Entry`。TUI 和 Web 通过 Cordis HMR(热模块替换)注册 `$DSH_HOME/config.yaml`;无头界面在启动时读取同一文件,但不保留监视器。监视器更新会重新构建 Include 补丁列表,先放置不可变的应用自有补丁,再放置新解析的个人补丁。因此,Web 生成的端口、会话根目录、信任和前端值会在每次个人编辑后保留,除非后续个人补丁有意替换相应配置项。 + +Cordis 会串行处理并合并该确切路径上的变更。Include 与 Loader 以事务方式协调候选配置:成功时提交新源列表;拉取、准备、包装模块导入、格式或子插件失败时拒绝候选配置,并保留或恢复最后一个可用树。HMR 会把捕获的值规范化为 `Error`,记录错误,并广播并行的 `hmr/config-update-failed(filename, error)` 事件;观察者失败不会中断刷新处理。MCP 传输连接失败仍沿用现有 MCP 客户端所收束的「插件成功加载但无工具」结果,因此不会被重新分类为配置更新失败。 + +相同说明符会永久复用同一个缓存版本。HMR 监视配置,而非已缓存的仓库代码;用户必须改变 ref、路径或源列表,才能选择另一个版本。 + +## 信任边界 + +配置仓库即授权该仓库及其依赖中的包管理器生命周期代码以用户的文件系统权限运行。pnpm 子进程会移除名称中含有 `KEY`、`PASSWORD`、`SECRET` 或 `TOKEN` 的环境变量,但这只会减少凭据暴露,并非沙箱。固定的运行时包装模块会阻止仓库作者提供的 Cordis 入口成为受支持插件格式的一部分;它无法让包准备过程安全执行不受信任的代码。 + +## 考虑过的替代方案 + +**要求声明 SDK 项目依赖。** 独立应用路径没有可编辑的项目 manifest(元数据清单),因此否决。开发者自有的 SDK 项目仍可使用原生包管理器工作流,这是一项独立能力。 + +**新增 `dsh plugin install` 命令和安装数据库。** 否决,因为个人 Loader 覆盖层已经负责机器本地组合。第二个变更接口和持久注册表会重复配置身份与回滚机制。 + +**由 DSH 包直接解析仓库。** 否决,因为 Git 传输、GitHub 子包选择、生命周期执行和内容存储属于 pnpm 与通用 Loader 缓存,而非 DSH 专用适配器。 + +**监视缓存内容,或自动刷新相同 ref。** 否决,因为一个配置值必须标识一个不可变的已准备版本。后台远端解析会在没有配置差异的情况下改变可执行代码,并使回滚依赖可变的远端状态。 + +**广播 `unknown` 失败载荷。** 在 HMR 边界否决。JavaScript 内部可以抛出任意值,但公开事件始终接收规范化的 `Error`,从而为观察者提供稳定契约,并在需要时把原始值保留为错误原因。 + +## 后果 + +- 添加 `.dsh-plugin/package.json` 的仓库只需一次个人配置编辑即可供独立用户使用,无需改变现有 skill 或 `.mcp.json` 布局。 +- 长时间运行的应用无需重启即可新增、替换或移除已配置版本;被拒绝的候选配置会保留最后一个可用运行时,并产生一个通用 Cordis 事件。 +- 首次使用可能需要 Git/网络访问和准备时间。后续启动会复用这份精确的已准备缓存;在另行制定缓存管理政策之前,旧版本会持续占用磁盘空间。 +- 仅支持 skill 和通用 MCP 定义。钩子、命令、agent(智能体)、应用、任意 Cordis 代码、兼容 shim、带 OAuth 的 MCP 定义和插件市场均有意不提供。 + +## 测试 + +仓库包测试固定源规范化、默认和嵌套 `.dsh-plugin` 路径、缓存根解析、重复项拒绝、已准备包装模块加载及资源释放。App-boot 测试通过真实 HMR/Include/Loader 路径驱动确切路径的新增、两类失败、恢复、移除、失败事件及生成补丁保留。一个无密钥 PTY 冒烟测试仅通过个人配置启动已交付的 `dsh` 组合,并从预置的不可变缓存版本中调用一个 skill。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index cfee5f6662..96a4588f2c 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: 6b67cbfccd21c6f1c32b5bc9417ab71309898b24 -README.zh.md: 71e242398fe56616c2b146562a6c7ce74fb0f6e1 +README.md: 76d9ed65398322cb9244a31661ee59b60c23f793 +README.zh.md: 16a7a4ec52b830e45c32a61a103d87be5941ab3b diff --git a/apps/cli/README.md b/apps/cli/README.md index 6b67cbfccd..76d9ed6539 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -11,7 +11,7 @@ The TUI surface: - 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)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`. +- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`. The shipped tree's Cordis HMR keeps `config.yaml` live; an explicit `--config` tree replaces that overlay, and a tree without HMR reads it at startup only. - presents the [versioned first-run welcome](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md) through the mounted TUI overlay service when its immutable marker is absent under `DSH_HOME`; only Enter creates that version's marker, while Escape, disposal, or process exit leaves it eligible. The official DeepSeek icon, responsive terminal rasters, all-locale Chinese copy, and notice version are static local owners; the overlay never writes a session event or model context. - registers bare `/compact`: while the agent is idle, it summarizes useful older history even below automatic pressure, rejects arguments, and reports success only after the standalone replacement bracket is durable. A prompt submitted during compaction keeps its queue identity and starts after that checkpoint; injected context remains visible. @@ -25,6 +25,18 @@ The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then The shared composition defaults new TUI, Web, and headless sessions to the `workspace-write` permission preset (`workspace-write` file mode plus `ask` approval policy). Sandbox-enforced bash and filesystem mutations may write only under the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. The browser answers one-shot approval requests and exposes the Access picker; the TUI exposes `/permission`, but has no approval-request answerer, so an automatic wider retry there fails closed until the user deliberately changes the session preset. `DSH_PERMISSION_MODE` changes the process fallback, while a stored General-settings Permission value applies to later sessions without changing an open one. +All three surfaces consume `$DSH_HOME/config.yaml`; the TUI and Web apply valid edits live, while one-shot headless runs read it at startup. The shipped trees include an empty `repository-plugins` row, so a standalone user can add prepared GitHub Plugins without an SDK project or install command: + +```yaml +- id: repository-plugins + name: '@deepseek-ai/dsh-repository-plugin' + config: + repositories: + - 'github:PolyArch/humanize#' +``` + +The repository must contain a prepared `.dsh-plugin` package; the [repository Plugin contract](../../packages/cordis/repository-plugin/README.md#standalone-app-configuration) documents authoring, nested Plugin paths, the immutable cache, trust boundary, and failure semantics. A failed live edit keeps the last good tree and emits Cordis's `hmr/config-update-failed` event. + 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. Every surface also registers `web_search` and only `web_search`. Search uses DeepSeek's Anthropic-compatible Messages endpoint, resolves the same `DEEPSEEK_API_KEY` reference for every call, and accepts the separate `DEEPSEEK_SEARCH_BASE_URL` endpoint override; each search is an auxiliary model request with its own latency and token cost. `web_fetch` remains disabled and the composition mounts no default fetch provider, so deployments that need arbitrary page retrieval must opt in through an overlay. The deployment decision and its security boundary live in the [default Web search Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md). diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 71e242398f..16a7a4ec52 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -11,7 +11,7 @@ TUI 界面: - 使用 `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)):`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env`。 +- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env`。已交付配置树中的 Cordis HMR 会持续应用 `config.yaml` 的变更;显式 `--config` 配置树会替代该个人覆盖,未包含 HMR 的配置树只在启动时读取该文件。 - 当 `DSH_HOME` 下不存在不可变确认标记时,通过已挂载的 TUI overlay 服务呈现[版本化首次运行欢迎页](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md);只有 Enter 会创建该版本的标记,Escape、资源释放或进程退出仍保留展示资格。官方 DeepSeek 图标、响应式终端栅格图、所有 locale 共用的中文文案和通知版本均由静态本地文件持有;overlay 不会写入会话事件或模型上下文。 - 注册裸 `/compact`:agent 空闲时,即使未达到自动压力,也会摘要有效的较早历史;该命令拒绝参数,并只在独立替换标记对持久化后报告成功。压缩(compaction)期间提交的提示词保留其队列身份,并在该检查点之后启动;注入的上下文仍保持可见。 @@ -25,6 +25,18 @@ Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 共享组合把新建 TUI、Web 和无头会话的权限默认设为 `workspace-write` preset(`workspace-write` 文件模式加 `ask` 审批策略)。由沙箱强制约束的 bash 与文件系统修改只能写入会话工作区和平台临时根目录;读取、网络访问和进程可见性不受该策略约束。浏览器可以应答一次性审批请求,并提供 Access 选择器;TUI 提供 `/permission`,但没有审批请求应答者,因此自动请求更宽权限的重试会以拒绝方式关闭,直到用户主动更改会话 preset。`DSH_PERMISSION_MODE` 会更改进程回退值,而「通用」设置中已存储的「权限」值只适用于之后的会话,不会更改已打开的会话。 +三个界面都会使用 `$DSH_HOME/config.yaml`;TUI 和 Web 实时应用有效编辑,而一次性无头运行只在启动时读取。已交付的配置树包含一个空的 `repository-plugins` 配置项,因此独立用户无需 SDK 项目或安装命令,只需配置即可添加已准备的 GitHub 插件: + +```yaml +- id: repository-plugins + name: '@deepseek-ai/dsh-repository-plugin' + config: + repositories: + - 'github:PolyArch/humanize#' +``` + +仓库必须包含已准备的 `.dsh-plugin` 包;[仓库插件契约](../../packages/cordis/repository-plugin/README.md#standalone-app-configuration)说明创作方式、嵌套插件路径、不可变缓存、信任边界和失败语义。实时编辑失败时,最后一个可用树保持运行,并发出 Cordis 的 HMR(热模块替换)事件 `hmr/config-update-failed`。 + 已交付的 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`。 每个界面也都只注册 `web_search` 这一个 Web 工具。搜索使用 DeepSeek 的 Anthropic 兼容 Messages 端点,每次调用都会解析同一个 `DEEPSEEK_API_KEY` 凭据引用,并接受独立的 `DEEPSEEK_SEARCH_BASE_URL` 端点覆盖;每次搜索都是一次辅助模型请求,会产生独立的延迟与 token 成本。`web_fetch` 仍处于禁用状态,组合也未挂载默认抓取提供方;需要任意页面抓取能力的部署必须通过覆盖层选择启用。部署决策及其安全边界见[默认 Web 搜索 Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md)。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index cb6b98a3b0..152b11d8c2 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -12,6 +12,8 @@ flowchart LR cfg --> plugin_tui_timer plugin_tui_hmr["hmr
@cordisjs/plugin-hmr"] cfg --> plugin_tui_hmr + plugin_tui_repository_plugins["repository-plugins
@deepseek-ai/dsh-repository-plugin"] + cfg --> plugin_tui_repository_plugins plugin_tui_llm["llm
@deepseek-ai/dsh-llm"] cfg --> plugin_tui_llm plugin_tui_session["session
@deepseek-ai/dsh-session"] @@ -144,6 +146,7 @@ flowchart LR | --- | --- | | `timer` | `@cordisjs/plugin-timer` | | `hmr` | `@cordisjs/plugin-hmr` | +| `repository-plugins` | `@deepseek-ai/dsh-repository-plugin` | | `llm` | `@deepseek-ai/dsh-llm` | | `session` | `@deepseek-ai/dsh-session` | | `session-title` | `@deepseek-ai/dsh-session-title` | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index e88cbdb39d..61d149ad83 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -22,6 +22,13 @@ config: root: ['.'] +# `$DSH_HOME/config.yaml` replaces this row's config to select exact GitHub +# repository Plugin generations. The app registers the DSH-owned runtime even +# when the list is empty so a later personal-config edit can load +# transactionally; one-shot headless runs consume the startup value only. +- id: repository-plugins + name: '@deepseek-ai/dsh-repository-plugin' + - id: llm name: '@deepseek-ai/dsh-llm' diff --git a/apps/cli/package.json b/apps/cli/package.json index 2269ceaffb..e6d853db37 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -77,6 +77,7 @@ "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-pty": "workspace:^", "@deepseek-ai/dsh-pty-local": "workspace:^", + "@deepseek-ai/dsh-repository-plugin": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 7b182b4073..eaa1902eff 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -1,6 +1,6 @@ /** * AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share - * for the Web/headless surface. + * (`dsh web` and `dsh -p`; the TUI composes dsh-app-boot directly). * Everything here is what must exist before the Loader runs: the patch * composition over the shipped base and surface overlay (profile json + CLI * flags + the resolved frontend dist), and the fail-loud activation audit after the tree @@ -16,7 +16,13 @@ import { join, resolve } from 'node:path' import { Context } from 'cordis' import type { PatchOptions } from '@cordisjs/plugin-include' import yaml from 'js-yaml' -import { boot, installFailLoud, loadOverlayPatches, loadPersonalPatches } from '@deepseek-ai/dsh-app-boot' +import { + boot, + installFailLoud, + loadOverlayPatches, + loadPersonalPatches, + watchPersonalPatches, +} from '@deepseek-ai/dsh-app-boot' // Empty type import carries the httpServer Context merge for the port read below. import type {} from '@deepseek-ai/dsh-host-webserver' @@ -140,8 +146,10 @@ export interface AppCLIEntryOptions { * `$DSH_HOME/config.yaml` overlay is applied instead. */ extraOverlayPath?: string - /** Whether to append the HMR row (the whole prod/dev difference; web surface only). */ + /** Whether to append client-bundle HMR (the Web surface's prod/dev difference). */ dev: boolean + /** Whether `$DSH_HOME/config.yaml` remains live after the initial boot. */ + watchPersonalConfig: boolean /** --host when explicitly passed; undefined keeps the yml engineering default. */ host?: string /** @@ -235,11 +243,12 @@ export class AppCLIEntry { // user config. Workspace knowledge stays here. put('webserver', 'distIndex', this.resolveDistIndex()) - this.patches = [...overrides.entries()].map(([id, bag]) => { + const generated = [...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}`) return { id, config: { ...(yml.config ?? {}) as Record, ...bag } } }) + this.patches = generated // Telemetry opt-out: a row can only be turned off at the patch layer // (config cannot disable an entry), and the switch must hold BEFORE the @@ -254,17 +263,30 @@ export class AppCLIEntry { // 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 = [ + const compose = (overlay: PatchOptions[]): PatchOptions[] => [ ...loadOverlayPatches('dsh', this.options.overlayPath), - ...this.options.extraOverlayPath === undefined - ? loadPersonalPatches('dsh') ?? [] - : loadOverlayPatches('dsh', this.options.extraOverlayPath), + ...overlay, ...this.patches, ] + // An explicit --config overlay REPLACES the personal overlay, so there is + // then no personal layer to keep live — the watcher is personal-only. + const watchPersonal = this.options.watchPersonalConfig && this.options.extraOverlayPath === undefined + const patches = compose( + this.options.extraOverlayPath === undefined + ? loadPersonalPatches('dsh') ?? [] + : loadOverlayPatches('dsh', this.options.extraOverlayPath), + ) this.ctx = await boot('dsh', resolve(this.options.configPath), patches, async (ctx) => { await this.options.prepare?.(ctx) + // Config-only HMR for the personal overlay: module reload stays off for + // this surface (web.cordis.yml disables the shared `hmr` row until its + // reload lifecycle is tested), so this row watches no module roots. + if (watchPersonal) await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } }) if (this.options.dev) await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' }) }) + if (watchPersonal) { + await watchPersonalPatches(this.ctx, { binName: 'dsh', compose }) + } } /** Install the diagnostic for plugin rejections that happen after settled boot. */ diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 3ec2792e8e..e41bc03c6c 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -78,6 +78,7 @@ export async function runHeadless(task: string): Promise { configPath: fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)), overlayPath: fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)), dev: false, + watchPersonalConfig: false, port: 0, }) const { ctx, port } = await entry.run() diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 93903b2fe6..32b767cb2c 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -28,8 +28,10 @@ import { loadOverlayPatches, loadPersonalPatches, resolveConfigPath, + watchPersonalPatches, } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import type { PatchOptions } from '@cordisjs/plugin-include' import { SessionId } from '@deepseek-ai/dsh-session' import { configHasTelemetryRow, resolveTelemetryPatch } from './app-cli-entry.ts' import { SESSION_QUERY_SQLITE_PATH_KEY } from '@deepseek-ai/dsh-session-query-sqlite' @@ -201,15 +203,16 @@ export async function runTui( // presence is checked against the tree actually booting, so a // --config-replace tree is judged on its own rows, not the shipped base's. const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, configHasTelemetryRow(bootConfig)) - const patches = [ + const composePatches = (personalPatches: PatchOptions[]): PatchOptions[] => [ ...replaceTree ? [] : [ ...loadOverlayPatches(NAME, TUI_OVERLAY), ...resolvedConfig === undefined - ? loadPersonalPatches(NAME) ?? [] + ? personalPatches : loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)), ], ...telemetryPatch === undefined ? [] : [telemetryPatch], ] + const patches = composePatches(loadPersonalPatches(NAME) ?? []) const queryIndexPath = join(tmpdir(), SESSION_QUERY_DB) const ctx = await boot( NAME, @@ -246,6 +249,14 @@ export async function runTui( } }, ) + // The shipped tree includes HMR and keeps personal config live. An explicit + // --config tree replaces the personal overlay (so there is nothing to keep + // live), and a --config-replace or HMR-less tree remains a valid composition + // that still receives the startup overlay but deliberately has no hidden + // watcher. + if (resolvedConfig === undefined && !replaceTree && ctx.get('hmr') !== undefined) { + await watchPersonalPatches(ctx, { binName: NAME, compose: composePatches }) + } app.current = ctx addHarnessSourceSection(ctx, SOURCE_ROOT) if (showFirstRunWelcome) { diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 815a53a11d..4fbfba4d8d 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -109,6 +109,7 @@ export async function runWeb( ...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) }, dev, prepare: (ctx) => { prepareWebRuntimeContext(ctx, SOURCE_ROOT, mode) }, + watchPersonalConfig: true, ...host !== undefined && { host }, ...port !== undefined && { port }, ...workspaceRoot !== undefined && { workspaceRoot }, diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 77e65e7d26..3cdf02e3ef 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -1,4 +1,5 @@ import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm' +import { createHash } from 'node:crypto' import { realpathSync } from 'node:fs' import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -640,6 +641,61 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('\u001B[?2004l') }, PTY_SMOKE_TEST_TIMEOUT_MS) + it('loads a cached repository Plugin from personal config alone', async () => { + const source = 'github:fixture/repository#fixed-ref' + const specifier = `${source}&path:/.dsh-plugin` + const key = createHash('sha256').update(specifier).digest('hex') + const packageRoot = `cache/repository-plugins/${key}/node_modules/repository` + const manifest = { name: 'config-only-fixture', skills: ['dsh-plugin-assets/skills/0'] } + const wrapper = [ + '// Generated by dsh-plugin-prepare. Do not edit.', + `const manifest = ${JSON.stringify(manifest)}`, + `export const name = ${JSON.stringify(manifest.name)}`, + "export const inject = ['loader']", + 'export async function apply(ctx) {', + " const runtime = ctx.loader.builtins['dsh-repository-plugin']", + " if (runtime === undefined) throw new Error('missing Cordis builtin dsh-repository-plugin')", + ' await ctx.plugin(runtime, { baseUrl: import.meta.url, manifest })', + '}', + '', + ].join('\n') + const output = await smoke({ + label: 'dsh personal repository Plugin', + tempDirPrefix: 'dsh-personal-repository-plugin-', + binScript: dshBinScript, + configArgs: [], + prepare: seedWorkspace({ + personal: { + 'config.yaml': [ + '- id: repository-plugins', + " name: '@deepseek-ai/dsh-repository-plugin'", + ' config:', + ' repositories:', + ` - '${source}'`, + '', + ].join('\n'), + [`cache/repository-plugins/${key}/.repository-cache.json`]: `${JSON.stringify({ specifier })}\n`, + [`${packageRoot}/dsh-plugin.mjs`]: wrapper, + [`${packageRoot}/dsh-plugin-assets/skills/0/config-only-repository/SKILL.md`]: [ + '---', + 'name: config-only-repository', + 'description: CONFIG_ONLY_REPOSITORY_SKILL', + '---', + '', + 'Repository instructions.', + '', + ].join('\n'), + }, + }), + actions: [ + { waitFor: 'main-session-', send: '/skill:config-only' }, + { waitFor: 'CONFIG_ONLY_REPOSITORY_SKILL', send: '\x03/exit\r' }, + ], + }) + expect(output).toContain('CONFIG_ONLY_REPOSITORY_SKILL') + expect(output).toContain('\u001B[?2004l') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('fails loud instead of booting when the personal config.yaml is invalid', async () => { const output = await smoke({ label: 'dsh invalid personal config', diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 14ce334f48..ed6b82ec83 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -995,6 +995,22 @@ export interface Config { Source: [`packages/guard/repeat-tool-guard/src/index.ts:28`](../packages/guard/repeat-tool-guard/src/index.ts) +## `@deepseek-ai/dsh-repository-plugin` + +Requires: `loader` + +```ts config-catalog +/** Repository Plugin runtime and source-list configuration. */ +export interface Config { + /** GitHub repository sources with explicit refs and optional `.dsh-plugin` subpaths. */ + repositories?: string[] + /** Persistent generation cache; defaults to `$DSH_HOME/cache/repository-plugins`. */ + cacheDir?: string +} +``` + +Source: [`packages/cordis/repository-plugin/src/index.ts:41`](../packages/cordis/repository-plugin/src/index.ts) + ## `@deepseek-ai/dsh-sandbox-local` ```ts config-catalog @@ -2322,7 +2338,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts)) -- `@deepseek-ai/dsh-repository-plugin` — requires `loader` ([`packages/cordis/repository-plugin/src/index.ts`](../packages/cordis/repository-plugin/src/index.ts)) - `@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)) diff --git a/docs/module-graph.md b/docs/module-graph.md index 9c3afcec85..5ba907f02f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -907,6 +907,7 @@ flowchart TD pkg_tool_subagent --> pkg_tools pkg_repository_plugin --> pkg_invariants pkg_repository_plugin --> pkg_mcp_client + pkg_repository_plugin --> pkg_paths pkg_repository_plugin --> pkg_skill_local pkg_hooks_claude --> pkg_agent pkg_hooks_claude --> pkg_hook_protocol @@ -1212,7 +1213,7 @@ flowchart TD | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | -| [`repository-plugin`](../packages/cordis/repository-plugin) | `cordis` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`skill-local`](../packages/skill/skill-local) | +| [`repository-plugin`](../packages/cordis/repository-plugin) | `cordis` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`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) | diff --git a/knip.json b/knip.json index 99b152738d..43102b0b0d 100644 --- a/knip.json +++ b/knip.json @@ -53,8 +53,7 @@ "**/*.ts" ], "ignoreDependencies": [ - "@deepseek-ai/.+", - "@cordisjs/.+" + "@deepseek-ai/.+" ] }, "packages/util/home": { @@ -631,8 +630,7 @@ "tests/**/*.ts" ], "ignoreDependencies": [ - "@deepseek-ai/.+", - "@cordisjs/.+" + "@deepseek-ai/.+" ] }, "packages/client/modules": { diff --git a/packages/cordis/repository-plugin/README.i18n.yaml b/packages/cordis/repository-plugin/README.i18n.yaml index 52f506c37c..8cd641781f 100644 --- a/packages/cordis/repository-plugin/README.i18n.yaml +++ b/packages/cordis/repository-plugin/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/cordis/repository-plugin/README.md -README.md: 80744eb489d1714f59ba6e53207476a8ce222e24 -README.zh.md: d297b44e4a065fa99865e3a42d2c823c7b7c5848 +README.md: 0ba1ce86d99a12e0f94e7a39fd3ae44dc29889a7 +README.zh.md: 2d9544166eafbb1066b65031969925890f2b9797 diff --git a/packages/cordis/repository-plugin/README.md b/packages/cordis/repository-plugin/README.md index 80744eb489..0ba1ce86d9 100644 --- a/packages/cordis/repository-plugin/README.md +++ b/packages/cordis/repository-plugin/README.md @@ -28,6 +28,23 @@ Place an ordinary package in the repository's `.dsh-plugin` directory: `dsh.skills` is an optional array of local skill roots. `dsh.mcpServers` is an optional path to one `.mcp.json`; at least one field is required. Paths are relative to `.dsh-plugin`, must stay under its parent source directory, and may therefore refer to existing repository assets such as `../skills`. A repository containing several Plugins gives each one its own `.dsh-plugin` package under a different selectable subdirectory. +## Standalone app configuration + +The shipped `dsh` TUI, Web, and headless trees contain an empty `repository-plugins` row. A standalone user enables exact GitHub generations by replacing that row's config in `$DSH_HOME/config.yaml` (default `~/.dsh/config.yaml`): + +```yaml +- id: repository-plugins + name: '@deepseek-ai/dsh-repository-plugin' + config: + repositories: + - 'github:PolyArch/humanize#' + - 'github:owner/repository#&path:/plugins/one/.dsh-plugin' +``` + +Each source must use `github:owner/repository#`. Omitting `&path:` selects `/.dsh-plugin`; an explicit path is absolute within the repository and must end in `.dsh-plugin`. A commit ref gives the clearest immutable identity, while tags and branches remain accepted exact config values. `cacheDir` may override the default `$DSH_HOME/cache/repository-plugins` cache root. + +The TUI and Web watch `config.yaml` through Cordis HMR. A valid source-list change installs and swaps the complete repository Plugin generation; a failed fetch, prepare, import, or Plugin application keeps the last good tree and broadcasts `hmr/config-update-failed(filename, error)`. Headless runs consume the file only at startup. An identical source string permanently reuses its prepared cache entry, so selecting changed code requires a ref, path, or other source-config change. App integration rationale: [config-only repository Plugins Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md). + ## Preparation `dsh-plugin-prepare` validates `package.json#dsh`, verifies skill-root types, parses the MCP file, copies assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. The wrapper contains only the normalized static manifest and fixed code that looks up the `dsh-repository-plugin` Loader builtin. It neither discovers nor compiles repository JavaScript, and the runtime never imports another repository entry point. diff --git a/packages/cordis/repository-plugin/README.zh.md b/packages/cordis/repository-plugin/README.zh.md index d297b44e4a..2d9544166e 100644 --- a/packages/cordis/repository-plugin/README.zh.md +++ b/packages/cordis/repository-plugin/README.zh.md @@ -28,6 +28,23 @@ `dsh.skills` 是可选的本地 skill 根数组。`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径;两者至少声明一个。路径相对于 `.dsh-plugin`,必须留在其父级源码目录下,因此可以引用 `../skills` 等仓库现有资源。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` package。 +## 独立应用配置 + +已交付的 `dsh` TUI、Web 和无头配置树包含一个空的 `repository-plugins` 配置项。独立用户只需在 `$DSH_HOME/config.yaml`(默认 `~/.dsh/config.yaml`)中替换该配置项的配置,即可启用精确指定的 GitHub generation: + +```yaml +- id: repository-plugins + name: '@deepseek-ai/dsh-repository-plugin' + config: + repositories: + - 'github:PolyArch/humanize#' + - 'github:owner/repository#&path:/plugins/one/.dsh-plugin' +``` + +每个源都必须采用 `github:owner/repository#`。省略 `&path:` 时选择 `/.dsh-plugin`;显式路径是仓库内的绝对路径,并且必须以 `.dsh-plugin` 结尾。commit ref 提供最清晰的不可变身份;tag 和 branch 仍可作为显式配置值使用。`cacheDir` 可覆盖默认缓存根 `$DSH_HOME/cache/repository-plugins`。 + +TUI 和 Web 通过 Cordis HMR(热模块替换)监视 `config.yaml`。有效的源列表变更会安装并替换整套仓库插件 generation;拉取、准备、导入或插件应用失败时,最后一个可用树保持运行,并广播 `hmr/config-update-failed(filename, error)`。无头运行只在启动时使用该文件。相同的源字符串会永久复用其已准备缓存条目,因此必须改变 ref、路径或其他源配置,才能选择发生变化的代码。应用集成依据见[仅凭配置接入仓库插件的 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md)。 + ## 准备阶段 `dsh-plugin-prepare` 校验 `package.json#dsh`、确认 skill 根类型、解析 MCP 文件、把资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。包装模块只包含规范化后的静态 manifest(元数据清单),以及查找 `dsh-repository-plugin` Loader builtin 的固定代码;它不会发现或编译仓库 JavaScript,运行时也不会导入仓库的其他入口。 diff --git a/packages/cordis/repository-plugin/package.json b/packages/cordis/repository-plugin/package.json index f6500eb007..07bfa924c2 100644 --- a/packages/cordis/repository-plugin/package.json +++ b/packages/cordis/repository-plugin/package.json @@ -34,6 +34,7 @@ "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-mcp-client": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-skill-local": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -44,6 +45,7 @@ "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/cordis/repository-plugin/src/index.ts b/packages/cordis/repository-plugin/src/index.ts index 26922654bb..73eb2dc473 100644 --- a/packages/cordis/repository-plugin/src/index.ts +++ b/packages/cordis/repository-plugin/src/index.ts @@ -8,8 +8,10 @@ import { dirname, isAbsolute, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import type { Context } from 'cordis' import type {} from '@cordisjs/plugin-loader' +import { RepositoryCache } from '@cordisjs/plugin-loader/repository' import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import * as McpClient from '@deepseek-ai/dsh-mcp-client' +import { z } from 'zod' import { REPOSITORY_PLUGIN_BUILTIN, isOutside, @@ -17,6 +19,11 @@ import { type PreparedPluginConfig, } from './format.ts' import { parseMcpDocument, resolveMcpServers } from './mcp.ts' +import { + loadPreparedRepository, + resolveRepositoryCacheDirectory, + resolveRepositorySpecifier, +} from './source.ts' export { PREPARED_ASSET_DIRECTORY, @@ -31,6 +38,19 @@ export const name = 'repository-plugin' /** Loader service required to register the fixed prepared-wrapper builtin. */ export const inject = ['loader'] +/** Repository Plugin runtime and source-list configuration. */ +export interface Config { + /** GitHub repository sources with explicit refs and optional `.dsh-plugin` subpaths. */ + repositories?: string[] + /** Persistent generation cache; defaults to `$DSH_HOME/cache/repository-plugins`. */ + cacheDir?: string +} + +export const Config = z.object({ + repositories: z.array(z.string().min(1)).default([]), + cacheDir: z.string().min(1).optional(), +}).strict().default({ repositories: [] }) + function preparedPath(baseUrl: string, configured: string): string { if (isAbsolute(configured)) throw new Error(`prepared DSH plugin path must be relative: ${JSON.stringify(configured)}`) const directory = dirname(fileURLToPath(baseUrl)) @@ -101,16 +121,25 @@ const preparedRuntime = { * Register the DSH-owned runtime as the Loader builtin used by fixed prepared wrappers. * @param ctx - plugin context carrying the Loader service. */ -export function apply(ctx: Context): void { +export async function apply(ctx: Context, config: Config = {}): Promise { if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] !== undefined) { throw new Error(`Loader builtin ${REPOSITORY_PLUGIN_BUILTIN} is already registered`) } - ctx.effect(function* () { + const repositories = (config.repositories ?? []).map(resolveRepositorySpecifier) + if (new Set(repositories).size !== repositories.length) { + throw new Error('repository sources must resolve to unique exact specifiers') + } + const cache = new RepositoryCache(resolveRepositoryCacheDirectory(config.cacheDir)) + await ctx.effect(async function* () { ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] = preparedRuntime yield () => { if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] === preparedRuntime) { Reflect.deleteProperty(ctx.loader.builtins, REPOSITORY_PLUGIN_BUILTIN) } } - }, 'repository-plugin Loader builtin') + for (const repository of repositories) { + const plugin = await loadPreparedRepository(ctx, cache, repository) + yield plugin.dispose + } + }, 'repository-plugin runtime and sources') } diff --git a/packages/cordis/repository-plugin/src/source.ts b/packages/cordis/repository-plugin/src/source.ts new file mode 100644 index 0000000000..c0362bf162 --- /dev/null +++ b/packages/cordis/repository-plugin/src/source.ts @@ -0,0 +1,74 @@ +/** + * GitHub repository source validation and prepared-wrapper loading. + * @module + */ + +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import type { Context, Fiber, Plugin } from 'cordis' +import type { RepositoryCache } from '@cordisjs/plugin-loader/repository' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { PREPARED_ENTRY_FILENAME } from './format.ts' + +/** Directory under the Harness home containing immutable repository generations. */ +export const DEFAULT_REPOSITORY_CACHE_DIRECTORY = 'repository-plugins' + +const GITHUB_SOURCE_PATTERN = /^github:([^/\s#&]+)\/([^/\s#&]+)#([^\s&]+)(?:&path:(\/[^\s&]+))?$/ + +function validPluginPath(path: string): boolean { + const segments = path.split('/').slice(1) + return segments.length > 0 + && segments.at(-1) === '.dsh-plugin' + && segments.every(segment => segment.length > 0 && segment !== '.' && segment !== '..') +} + +/** + * Normalize one user-facing GitHub source to the exact pnpm dependency specifier. + * @param configured - `github:owner/repo#ref` with an optional `&path:/.../.dsh-plugin`. + * @returns the exact specifier, with the root `.dsh-plugin` subpath added when omitted. + * @throws when the GitHub owner, repository, explicit ref, or plugin subpath is invalid. + */ +export function resolveRepositorySpecifier(configured: string): string { + const match = GITHUB_SOURCE_PATTERN.exec(configured) + if (match === null) { + throw new Error(`repository source must use github:owner/repo# with an optional &path:/.../.dsh-plugin: ${JSON.stringify(configured)}`) + } + const path = match[4] + if (path !== undefined && !validPluginPath(path)) { + throw new Error(`repository source path must be an absolute repository subpath ending in .dsh-plugin without empty, . or .. segments: ${JSON.stringify(path)}`) + } + return path === undefined ? `${configured}&path:/.dsh-plugin` : configured +} + +/** + * Resolve the persistent repository cache root. + * @param configured - explicit cache directory, or undefined for `$DSH_HOME/cache/repository-plugins`. + * @returns an absolute cache directory. + */ +export function resolveRepositoryCacheDirectory(configured: string | undefined): string { + return resolve(configured ?? join(resolveDshHome(), 'cache', DEFAULT_REPOSITORY_CACHE_DIRECTORY)) +} + +/** + * Load one exact repository generation's generated wrapper as a child Cordis fiber. + * @param ctx - repository runtime context that owns the child. + * @param cache - package-manager-native immutable repository cache. + * @param specifier - normalized exact pnpm dependency specifier. + * @returns the settled prepared-wrapper fiber. + * @throws when installation, wrapper import, manifest validation, or child registration fails. + */ +export async function loadPreparedRepository( + ctx: Context, + cache: Pick, + specifier: string, +): Promise { + const directory = await cache.resolve(specifier) + const filename = join(directory, PREPARED_ENTRY_FILENAME) + try { + const plugin = await import(/* @vite-ignore */pathToFileURL(filename).href) as Plugin + const fiber = ctx.plugin(plugin) + return await fiber + } catch (cause) { + throw new Error(`failed to load prepared repository Plugin ${JSON.stringify(specifier)} from ${filename}`, { cause }) + } +} diff --git a/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts b/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts index e26f267e72..06d0c9d322 100644 --- a/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts +++ b/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts @@ -2,9 +2,10 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, relative, resolve } from 'node:path' import { pathToFileURL } from 'node:url' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' +import { RepositoryCache } from '@cordisjs/plugin-loader/repository' import SkillService from '@deepseek-ai/dsh-skill' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' @@ -12,6 +13,11 @@ import InvariantService from '@deepseek-ai/dsh-invariants' import * as RepositoryPlugin from '@deepseek-ai/dsh-repository-plugin' import * as RepositoryPluginInvariant from '@deepseek-ai/dsh-repository-plugin/invariant' import { parsePreparedPluginConfig } from '../src/format.ts' +import { + loadPreparedRepository, + resolveRepositoryCacheDirectory, + resolveRepositorySpecifier, +} from '../src/source.ts' const roots: string[] = [] @@ -35,6 +41,8 @@ async function writeSkill(root: string, name: string): Promise { } afterEach(async () => { + vi.restoreAllMocks() + vi.unstubAllEnvs() await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) }) @@ -253,7 +261,7 @@ describe('prepared repository plugin Loader composition', () => { await ctx.plugin(Loader) const registrar = ctx.plugin(RepositoryPlugin) await registrar - expect(() => { RepositoryPlugin.apply(ctx) }).toThrow('already registered') + await expect(RepositoryPlugin.apply(ctx)).rejects.toThrow('already registered') const replacement = { name: 'replacement', apply() {} } ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN] = replacement @@ -263,6 +271,113 @@ describe('prepared repository plugin Loader composition', () => { }) }) +describe('configured GitHub repository sources', () => { + it('defaults an omitted source list and rejects unknown configuration fields', () => { + expect(RepositoryPlugin.Config.parse(undefined)).toEqual({ repositories: [] }) + expect(RepositoryPlugin.Config.safeParse({ repositories: [], unexpected: true }).success).toBe(false) + }) + + it('accepts an empty direct-apply config', async () => { + const ctx = new Context() + await ctx.plugin(Loader) + await RepositoryPlugin.apply(ctx, {}) + expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeDefined() + await ctx.fiber.dispose() + }) + + it('adds the root plugin subpath and preserves an explicit nested plugin subpath', () => { + expect(resolveRepositorySpecifier('github:PolyArch/humanize#v1.0.0')) + .toBe('github:PolyArch/humanize#v1.0.0&path:/.dsh-plugin') + expect(resolveRepositorySpecifier('github:owner/repository#feature/ref&path:/plugins/one/.dsh-plugin')) + .toBe('github:owner/repository#feature/ref&path:/plugins/one/.dsh-plugin') + }) + + it('rejects absent refs and invalid plugin subpaths', () => { + for (const source of [ + 'github:owner/repository', + 'github:owner/repository#', + 'https://github.com/owner/repository#ref', + 'github:owner/repository#ref&path:relative/.dsh-plugin', + ]) { + expect(() => resolveRepositorySpecifier(source)).toThrow('must use github:owner/repo#') + } + for (const path of [ + '/plugins//.dsh-plugin', + '/plugins/../.dsh-plugin', + '/plugins/./.dsh-plugin', + '/plugins/not-a-plugin', + ]) { + expect(() => resolveRepositorySpecifier(`github:owner/repository#ref&path:${path}`)) + .toThrow('path must be an absolute repository subpath') + } + }) + + it('resolves the default cache under DSH_HOME and an explicit cache absolutely', async () => { + const root = await temporaryDirectory('cache-root') + vi.stubEnv('DSH_HOME', root) + expect(resolveRepositoryCacheDirectory(undefined)).toBe(join(root, 'cache', 'repository-plugins')) + expect(resolveRepositoryCacheDirectory(join(root, 'explicit'))).toBe(join(root, 'explicit')) + }) + + it('loads a configured source through the immutable cache and removes its skill on teardown', async () => { + const root = await temporaryDirectory('configured-source') + await writeSkill(join(root, 'skills'), 'configured-repository-skill') + const directory = await writePlugin(root, 'configured-source-fixture', { skills: ['../skills'] }) + await RepositoryPlugin.prepareDshPlugin(directory) + const resolved: string[] = [] + const cacheDirectory = join(root, 'cache') + vi.spyOn(RepositoryCache.prototype, 'resolve').mockImplementation(async function (this: RepositoryCache, specifier) { + expect(this.directory).toBe(cacheDirectory) + resolved.push(specifier) + return directory + }) + + const ctx = new Context() + await ctx.plugin(Loader) + await ctx.plugin(SkillService) + const registrar = ctx.plugin(RepositoryPlugin, { + repositories: ['github:owner/repository#fixed-ref'], + cacheDir: cacheDirectory, + }) + await registrar + expect(resolved).toEqual(['github:owner/repository#fixed-ref&path:/.dsh-plugin']) + await expect(ctx.skills.get('configured-repository-skill')).resolves.toMatchObject({ + provider: 'repository:configured-source-fixture', + }) + + await registrar.dispose() + await expect(ctx.skills.get('configured-repository-skill')).resolves.toBeUndefined() + await ctx.fiber.dispose() + }) + + it('rejects duplicate generations and cleans the builtin after cache preparation fails', async () => { + const ctx = new Context() + await ctx.plugin(Loader) + await expect(RepositoryPlugin.apply(ctx, { + repositories: [ + 'github:owner/repository#ref', + 'github:owner/repository#ref', + ], + })).rejects.toThrow('must resolve to unique exact specifiers') + + vi.spyOn(RepositoryCache.prototype, 'resolve').mockRejectedValue(new Error('prepare failed')) + await expect(RepositoryPlugin.apply(ctx, { + repositories: ['github:owner/repository#other'], + })).rejects.toThrow('prepare failed') + expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('labels a missing prepared wrapper with its exact source and path', async () => { + const root = await temporaryDirectory('missing-wrapper') + const ctx = new Context() + const specifier = 'github:owner/repository#missing&path:/.dsh-plugin' + await expect(loadPreparedRepository(ctx, { resolve: async () => root }, specifier)) + .rejects.toThrow(`failed to load prepared repository Plugin ${JSON.stringify(specifier)}`) + await ctx.fiber.dispose() + }) +}) + describe('repository plugin invariant companion', () => { it('registers its explained empty invariant', async () => { const ctx = new Context() diff --git a/packages/cordis/repository-plugin/tsconfig.json b/packages/cordis/repository-plugin/tsconfig.json index f7918dcdd9..67cb0dedf2 100644 --- a/packages/cordis/repository-plugin/tsconfig.json +++ b/packages/cordis/repository-plugin/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../mcp/mcp-client" }, + { + "path": "../../util/paths" + }, { "path": "../../support/invariants" } diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 6d7aa6f0d3..65a471f69a 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: e82d378f9cabd24d0f8b3069237f142c1885191f -README.zh.md: 5d749531e48a502291491e6d047b45cd8a505544 +README.md: 2eb9e904d574df39b0884558fc0a53f9dc04cdc1 +README.zh.md: 78bd99943fcadebf42a5d772d49f3bfcf6a8790a diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index e82d378f9c..2eb9e904d5 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -13,6 +13,8 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c | `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | | `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 | | `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error | +| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Mount the statically imported Include builtin and retain the exact root entry used by personal-config HMR | +| `watchPersonalPatches(ctx, options)` | Register `$DSH_HOME/config.yaml` with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current personal overlay) and returns an async disposer | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, 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 activated, and return the root context — or dispose the partial context and reject a labelled error | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of same-provenance rows is preceded by a `# ==` comment naming the contributing file and the layers that patched it, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw | | `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 the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; 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 | @@ -26,11 +28,13 @@ 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 official `dsh` surfaces ([`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 `dsh` CLI's TUI, Web, and headless surfaces ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files: - **`.env`** — the credential store of [`dsh-credentials-local`](../../credentials/credentials-local/README.md), read by that provider alone. No surface hoists it into `process.env`: doing so would make every stored key look like a read-only launch override on the next run, blocking rotation from the TUI and the web page. The environment layers are the ambient one and the invoking directory's `.env` (loaded by the bin; `process.loadEnvFile` never overrides), and a composition without the credential provider keeps resolving keys from those alone. - **`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. 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. +The TUI and Web keep `config.yaml` live through `watchPersonalPatches`; one-shot headless runs read only the startup value. The watcher targets the exact personal path even when the file or immediate parent does not exist, serializes bursts, and recomposes the personal patches inside the caller's layer order (surface overlay below, app-generated patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh. + Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures. ## Model Experience diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index 5d749531e4..78bd99943f 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -13,6 +13,8 @@ | `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | | `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | | `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 | +| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 挂载静态导入的 Include builtin,并保留个人配置 HMR(热模块替换)使用的确切根配置项 | +| `watchPersonalPatches(ctx, options)` | 向现有 Cordis HMR 服务注册 `$DSH_HOME/config.yaml`;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前个人 overlay)以事务方式重新组合完整 patch 列表,并返回异步 disposer | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献该段的文件以及修补过它的层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | @@ -26,11 +28,13 @@ Loader 结算会在导入或生命周期失败时 reject,并携带失败的配 ## 个人配置 -开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由官方 `dsh` 界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件: +开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由 `dsh` CLI(命令行界面)的 TUI、Web 和无头界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件: - **`.env`**:[`dsh-credentials-local`](../../credentials/credentials-local/README.md) 的凭据存储,只由该 provider 读取。没有任何表层会把它提升进 `process.env`:那样做会让每个已存密钥在下次运行时看起来都像只读的启动时覆盖,从而阻断从 TUI 与 Web 页面轮换密钥。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据 provider 的组合仍然只从这两者解析密钥。 - **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。 +TUI 和 Web 会持续应用 `config.yaml` 的变更,具体由 `watchPersonalPatches` 负责;一次性无头运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切的个人配置路径;它会串行处理突发变更,并按调用方的层次顺序重新组合个人 patch(surface overlay 在下、应用生成的 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 + 子进程测试 launcher 会把 `DSH_HOME` 指向逐测试隔离的目录,确保开发者的个人 overlay 不会泄漏到 fixture(测试前置数据)中。 ## 模型体验 diff --git a/packages/ui/app-boot/package.json b/packages/ui/app-boot/package.json index 68062cb480..18a42a27a1 100644 --- a/packages/ui/app-boot/package.json +++ b/packages/ui/app-boot/package.json @@ -30,6 +30,7 @@ "js-yaml": "^4.2.0" }, "peerDependencies": { + "@cordisjs/plugin-hmr": "^1.0.15", "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-invariants": "^0.0.1", @@ -37,6 +38,11 @@ "@deepseek-ai/dsh-system-prompt": "^0.0.1", "cordis": "^4.0.0-rc.7" }, + "peerDependenciesMeta": { + "@cordisjs/plugin-hmr": { + "optional": true + } + }, "devDependencies": { "@cordisjs/plugin-hmr": "workspace:^", "@cordisjs/plugin-include": "workspace:^", diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 7897c5fb89..26747ef545 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -11,9 +11,10 @@ import { readFileSync } from 'node:fs' import { basename, dirname, join, resolve } from 'node:path' import * as yaml from 'js-yaml' import { Context, type FiberState } from 'cordis' -import Loader, { type EntryOptions } from '@cordisjs/plugin-loader' +import Loader, { type Entry, type EntryOptions } from '@cordisjs/plugin-loader' import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths' +import type {} from '@cordisjs/plugin-hmr' // Side-effect type import: resolves `ctx.get('systemPrompt')` to the service. import type {} from '@deepseek-ai/dsh-system-prompt' @@ -67,6 +68,8 @@ export function loadEnv( /** File inside the Harness home holding the personal loader overlay patches. */ export const PERSONAL_CONFIG_FILENAME = 'config.yaml' +const bootstrapIncludes = new WeakMap() + // The include's YAML dialect (`!!js` scalars become expression nodes the // Loader interpolates against each entry's context at mount time), imported // from the include itself so patch parsing and config dumping can never drift @@ -287,6 +290,86 @@ function groupedDump( return lines.join('\n') + '\n' } +/** Options for live personal-config reconciliation. */ +export interface PersonalPatchWatchOptions { + /** Diagnostic prefix used by {@link loadPersonalPatches}. */ + binName: string + /** Harness home containing `config.yaml`; defaults to {@link resolveDshHome}. */ + dir?: string + /** + * Compose the full patch list for a fresh personal-overlay generation — + * the same composition the app booted with, so a reload can interleave the + * new personal patches between app-owned layers (surface overlay below, + * profile/flag patches above). Identity when omitted: the personal overlay + * is the whole patch list. + */ + compose?: (personalPatches: PatchOptions[]) => PatchOptions[] +} + +/** + * Watch the personal overlay through Cordis HMR and transactionally reapply it to the boot include. + * @param ctx - settled app context containing the root Include and an active HMR service. + * @param options - diagnostic, Harness-home, and patch-composition inputs. + * @returns an asynchronous disposer after the exact-path watcher is ready. + * @throws when HMR or the root Include is absent, watcher setup fails, or initial path resolution fails. + */ +export async function watchPersonalPatches( + ctx: Context, + options: PersonalPatchWatchOptions, +): Promise<() => Promise> { + const { binName, dir = resolveDshHome(), compose = (patches: PatchOptions[]) => patches } = options + const hmr = ctx.get('hmr') + if (hmr === undefined) throw new Error(`${binName}: personal config watching requires the Cordis HMR service`) + const entry = bootstrapIncludes.get(ctx) + if (entry === undefined) throw new Error(`${binName}: personal config watching requires the root Include entry`) + const filename = join(dir, PERSONAL_CONFIG_FILENAME) + const { patches: _initialPatches, ...includeConfig } = entry.options.config as Include.Config + return hmr.registerConfig(filename, async () => { + const personalPatches = loadPersonalPatches(binName, dir) ?? [] + const patches = compose(personalPatches) + await entry.update({ + config: { + ...includeConfig, + patches, + }, + }) + }) +} + +/** + * Mount and remember the exact root Include entry used by app boot and personal-config HMR. + * @param ctx - context carrying an initialized Loader service. + * @param absoluteConfigPath - absolute YAML or JSON configuration path. + * @param patches - initial app and personal patches, applied in order. + * @returns the created root Include entry, or `undefined` when a surface + * disposed the whole tree (taking the Loader service with it) while the + * transactional create was still settling entry lifecycle. + */ +export async function mountRootInclude( + ctx: Context, + absoluteConfigPath: string, + patches: readonly PatchOptions[] = [], +): Promise { + ctx.loader.builtins.include = Include + // Pinned id: the bootstrap include is app glue, not a config row, and its + // id appears in Loader failure chains — a random id would make startup + // diagnostics unstable across runs (and snapshot fixtures). + const rootInclude: EntryOptions = { + id: 'include', + name: 'cordis:include', + config: { + path: pathToFileURL(absoluteConfigPath).href, + ...patches.length > 0 ? { patches: [...patches] } : {}, + }, + } + const includeId = await ctx.loader.create(rootInclude) + const loader = ctx.get('loader') + if (loader === undefined) return undefined + const entry = loader.resolve(includeId) + bootstrapIncludes.set(ctx, entry) + return entry +} + /** * The slice of `process` {@link installFailLoud} needs — injectable so tests * exercise the handler without registering on (or exiting) the real process. @@ -463,21 +546,9 @@ export async function boot( ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' ctx.provide('dshHomePath', dshHomePath) await ctx.plugin(Loader) - ctx.loader.builtins.include = Include await prepare?.(ctx) stage = 'plugin tree failed to load' - // Pinned id: the bootstrap include is app glue, not a config row, and its - // id appears in Loader failure chains — a random id would make startup - // diagnostics unstable across runs (and snapshot fixtures). - const rootInclude: EntryOptions = { - id: 'include', - name: 'cordis:include', - config: { - path: pathToFileURL(absoluteConfigPath).href, - ...patches !== undefined && patches.length > 0 ? { patches } : {}, - }, - } - await ctx.loader.create(rootInclude) + await mountRootInclude(ctx, absoluteConfigPath, patches) // A surface can finish and dispose the whole tree while startup is still // in flight: the TUI renders as soon as its own fiber starts, so an `/exit` // typed before the last entry settles tears the context down under us. The diff --git a/packages/ui/app-boot/tests/personal-config.spec.ts b/packages/ui/app-boot/tests/personal-config.spec.ts index 5d72238cfa..62003599d8 100644 --- a/packages/ui/app-boot/tests/personal-config.spec.ts +++ b/packages/ui/app-boot/tests/personal-config.spec.ts @@ -4,21 +4,36 @@ * a real Loader tree. */ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import type { Context } from 'cordis' +import { Context } from 'cordis' +import Hmr from '@cordisjs/plugin-hmr' +import Loader from '@cordisjs/plugin-loader' +import Timer from '@cordisjs/plugin-timer' import { boot, loadPersonalPatches, PERSONAL_CONFIG_FILENAME, + watchPersonalPatches, } from '../src/index.ts' const NAME = 'dsh-test-bin' const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-personal-config-')) +async function eventually(test: () => boolean, message: string): Promise { + const deadline = Date.now() + 10_000 + while (!test()) { + if (Date.now() >= deadline) throw new Error(message) + await new Promise(resolve => setTimeout(resolve, 10)) + } +} + +const settleChokidarChangeThrottle = (): Promise => new Promise(resolve => setTimeout(resolve, 75)) + describe('loadPersonalPatches', () => { afterEach(() => { delete process.env.DSH_HOME @@ -86,7 +101,13 @@ describe('loadPersonalPatches', () => { describe('boot with personal patches', () => { function writeTree(dir: string): string { - writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n') + writeFileSync(join(dir, 'noop.mjs'), [ + 'export const name = "noop"', + 'export function apply(_ctx, config = {}) {', + ' if (config.fail) throw new Error("candidate config failed")', + '}', + '', + ].join('\n')) writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n') return join(dir, 'cordis.yml') } @@ -138,4 +159,78 @@ describe('boot with personal patches', () => { await ctxEmpty.fiber.dispose() } }) + + it('watches add, failure, recovery, and removal through transactional HMR', { timeout: 20_000 }, async () => { + const dir = tmp() + const personal = tmp() + const filename = join(personal, PERSONAL_CONFIG_FILENAME) + const basePatches = [{ id: 'noop', config: { value: 'generated' } }] + const ctx = await boot(NAME, writeTree(dir), basePatches) + await ctx.plugin(Timer) + await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + const failures: Array<{ filename: string; error: Error }> = [] + ctx.on('hmr/config-update-failed', (failedFilename, error) => { + failures.push({ filename: failedFilename, error }) + }) + const dispose = await watchPersonalPatches(ctx, { + binName: NAME, + dir: personal, + compose: personalPatches => [...basePatches, ...personalPatches], + }) + try { + writeFileSync(filename, '- id: noop\n config:\n value: live\n') + await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'live', 'personal config addition was not applied') + + writeFileSync(filename, '- id: noop\n config:\n fail: true\n') + await eventually(() => failures.length === 1, 'failed candidate was not broadcast') + expect(failures[0]).toMatchObject({ filename }) + expect(failures[0]?.error).toBeInstanceOf(Error) + expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live') + await settleChokidarChangeThrottle() + + writeFileSync(filename, 'invalid: [unclosed\n') + await eventually(() => failures.length === 2, 'parse failure was not broadcast') + expect(failures[1]?.error).toBeInstanceOf(Error) + expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live') + await settleChokidarChangeThrottle() + + writeFileSync(filename, '- id: noop\n config:\n value: recovered\n') + await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'recovered', 'valid recovery was not applied') + await settleChokidarChangeThrottle() + + unlinkSync(filename) + await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'generated', 'personal config removal did not restore the app-owned patch') + expect(failures).toHaveLength(2) + await settleChokidarChangeThrottle() + + // Default compose: the personal overlay IS the whole patch list, so a + // fresh generation replaces the app-owned layer instead of stacking on it. + await dispose() + const disposeDefault = await watchPersonalPatches(ctx, { binName: NAME, dir: personal }) + try { + writeFileSync(filename, '- id: noop\n config:\n value: identity\n') + await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose personal patch was not applied') + } finally { + await disposeDefault() + } + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + + it('fails loud when the exact watcher lacks HMR or a root Include', async () => { + const dir = tmp() + const withoutHmr = await boot(NAME, writeTree(dir)) + await expect(watchPersonalPatches(withoutHmr, { binName: NAME, dir: tmp() })).rejects.toThrow('requires the Cordis HMR service') + await withoutHmr.fiber.dispose() + + const withoutInclude = new Context() + withoutInclude.baseUrl = pathToFileURL(`${tmp()}/`).href + await withoutInclude.plugin(Loader) + await withoutInclude.plugin(Timer) + await withoutInclude.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + await expect(watchPersonalPatches(withoutInclude, { binName: NAME, dir: tmp() })).rejects.toThrow('requires the root Include entry') + await withoutInclude.fiber.dispose() + }) }) diff --git a/packages/ui/app-boot/tests/repository-cache.spec.ts b/packages/ui/app-boot/tests/repository-cache.spec.ts index 33a223020f..b7d80470b8 100644 --- a/packages/ui/app-boot/tests/repository-cache.spec.ts +++ b/packages/ui/app-boot/tests/repository-cache.spec.ts @@ -112,17 +112,27 @@ describe('RepositoryCache', () => { await expect(cache.resolve(specifier)).rejects.toThrow('repository cache marker is invalid') }) - it('runs a Git dependency prepare script through the bundled pnpm', { timeout: 60_000 }, async () => { + it('selects and prepares a root .dsh-plugin Git subpath through the bundled pnpm', { timeout: 60_000 }, async () => { const root = await temporaryRoot('repository-pnpm') const repository = join(root, 'source') - await mkdir(repository) + await mkdir(join(repository, '.dsh-plugin'), { recursive: true }) + await mkdir(join(repository, 'skills', 'fixture'), { recursive: true }) await writeFile(join(repository, 'package.json'), `${JSON.stringify({ name: 'repository-fixture', version: '1.0.0', - scripts: { prepare: 'node prepare.mjs' }, })}\n`) - await writeFile(join(repository, 'prepare.mjs'), [ - "import { writeFile } from 'node:fs/promises'", + await writeFile(join(repository, 'skills', 'fixture', 'SKILL.md'), 'repository skill source\n') + await writeFile(join(repository, '.dsh-plugin', 'package.json'), `${JSON.stringify({ + name: 'repository-plugin-fixture', + version: '1.0.0', + scripts: { prepare: 'node prepare.mjs' }, + dsh: { skills: ['../skills'] }, + })}\n`) + await writeFile(join(repository, '.dsh-plugin', 'prepare.mjs'), [ + "import { cp, mkdir, writeFile } from 'node:fs/promises'", + "await mkdir('dsh-plugin-assets/skills', { recursive: true })", + "await cp('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })", + "await writeFile('dsh-plugin.mjs', 'export function apply() {}\\n')", "await writeFile('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)", '', ].join('\n')) @@ -134,11 +144,16 @@ describe('RepositoryCache', () => { 'commit', '--quiet', '-m', 'fixture', ], { cwd: repository }) const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repository, encoding: 'utf8' }) - const specifier = `git+${pathToFileURL(repository).href}#${stdout.trim()}` + const specifier = `git+${pathToFileURL(repository).href}#${stdout.trim()}&path:/.dsh-plugin` vi.stubEnv('REPOSITORY_TEST_VISIBLE', 'visible') vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden') const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier) await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent\n') + await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply') + await expect(readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8')) + .resolves.toBe('repository skill source\n') + await expect(readFile(join(installed, 'package.json'), 'utf8')) + .resolves.toContain('repository-plugin-fixture') }) }) diff --git a/packages/ui/app-boot/tsconfig.json b/packages/ui/app-boot/tsconfig.json index 23f83dda51..beb61317dc 100644 --- a/packages/ui/app-boot/tsconfig.json +++ b/packages/ui/app-boot/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/include" }, + { + "path": "../../../vendor/hmr" + }, { "path": "../../support/invariants" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4eb0aedbfb..7ec61249d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -312,6 +312,9 @@ importers: '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:^ version: link:../../packages/guard/repeat-tool-guard + '@deepseek-ai/dsh-repository-plugin': + specifier: workspace:^ + version: link:../../packages/cordis/repository-plugin '@deepseek-ai/dsh-sandbox-local': specifier: workspace:^ version: link:../../packages/sandbox/sandbox-local @@ -2368,6 +2371,9 @@ importers: '@deepseek-ai/dsh-mcp-client': specifier: workspace:^ version: link:../../mcp/mcp-client + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../skill/skill From b6284c84672596e6268e471feb9b0c85788d2ead Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:26:47 +0800 Subject: [PATCH 20/23] fix(review): audit prepared-wrapper activation and pin generated shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ds-review-bot round 1 on the DSH-home integration: - generated wrappers now inject the services their manifest needs (skills/ tools beside loader), and loadPreparedRepository rejects a wrapper fiber that settles anything but ACTIVE — a composition missing a required service fails the repository transaction instead of committing an ACTIVE row over a silently PENDING child (critical finding) - the github: source ref segment excludes '#', so 'a#b' refs fail at the config parser with the promised syntax instead of inside pnpm - watchPersonalPatches re-reads the include's non-patch options per refresh instead of a registration-time snapshot - the TUI smoke's cache-seeded wrapper is produced by the real prepareDshPlugin (cache LAYOUT stays a deliberate external pin) - new Loader integration test drives a live repositories update through entry.update: generation swap, old skills removed, failed candidate rolled back to the previous generation --- apps/cli/tests/tui-keyless-smoke.e2e.ts | 52 ++++++++++---- docs/config-catalog.md | 2 +- .../cordis/repository-plugin/src/source.ts | 24 ++++++- .../tests/repository-plugin.spec.ts | 69 +++++++++++++++++++ packages/ui/app-boot/src/index.ts | 5 +- 5 files changed, 135 insertions(+), 17 deletions(-) diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 3cdf02e3ef..f61d37360b 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -7,6 +7,7 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke' +import { PREPARED_ENTRY_FILENAME, prepareDshPlugin } from '@deepseek-ai/dsh-repository-plugin' import { packChunkRuns, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' import { logPath, toHeaderLine } from '../../../packages/session-persistence/session-persistence-jsonl/src/format.ts' import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts' @@ -67,6 +68,38 @@ function seedWorkspace( } } +/** + * Run the real `prepareDshPlugin` over an equivalent one-skill `.dsh-plugin` + * package and return the generated wrapper text, so the smoke's cache-seeded + * wrapper can never drift from the generator's template. + */ +async function generatePreparedWrapper(pluginName: string): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-smoke-wrapper-')) + try { + const plugin = join(root, '.dsh-plugin') + await mkdir(join(root, 'skills', 'config-only-repository'), { recursive: true }) + await writeFile(join(root, 'skills', 'config-only-repository', 'SKILL.md'), [ + '---', + 'name: config-only-repository', + 'description: Generator input; the seeded cache copy owns the visible text.', + '---', + '', + 'Repository instructions.', + '', + ].join('\n')) + await mkdir(plugin, { recursive: true }) + await writeFile(join(plugin, 'package.json'), `${JSON.stringify({ + name: pluginName, + version: '0.0.0', + dsh: { skills: ['../skills'] }, + }, undefined, 2)}\n`) + await prepareDshPlugin(plugin) + return await readFile(join(plugin, PREPARED_ENTRY_FILENAME), 'utf8') + } finally { + await rm(root, { recursive: true, force: true }) + } +} + /** Seed one real plaintext JSONL session for the `/resume` selector and host handoff smoke. */ async function seedResumeSession(cwd: string): Promise { const sessionCwd = realpathSync.native(cwd) @@ -646,19 +679,12 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { const specifier = `${source}&path:/.dsh-plugin` const key = createHash('sha256').update(specifier).digest('hex') const packageRoot = `cache/repository-plugins/${key}/node_modules/repository` - const manifest = { name: 'config-only-fixture', skills: ['dsh-plugin-assets/skills/0'] } - const wrapper = [ - '// Generated by dsh-plugin-prepare. Do not edit.', - `const manifest = ${JSON.stringify(manifest)}`, - `export const name = ${JSON.stringify(manifest.name)}`, - "export const inject = ['loader']", - 'export async function apply(ctx) {', - " const runtime = ctx.loader.builtins['dsh-repository-plugin']", - " if (runtime === undefined) throw new Error('missing Cordis builtin dsh-repository-plugin')", - ' await ctx.plugin(runtime, { baseUrl: import.meta.url, manifest })', - '}', - '', - ].join('\n') + // Produced by the real generator (prepareDshPlugin over an equivalent + // .dsh-plugin package) rather than hand-written, so a wrapper-template + // change cannot leave this smoke exercising a stale shape. The cache + // LAYOUT below (sha256 key, marker, node_modules/repository) remains a + // deliberate external pin of the durable on-disk format. + const wrapper = await generatePreparedWrapper('config-only-fixture') const output = await smoke({ label: 'dsh personal repository Plugin', tempDirPrefix: 'dsh-personal-repository-plugin-', diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ed6b82ec83..ad12c61134 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1009,7 +1009,7 @@ export interface Config { } ``` -Source: [`packages/cordis/repository-plugin/src/index.ts:41`](../packages/cordis/repository-plugin/src/index.ts) +Source: [`packages/cordis/repository-plugin/src/index.ts:42`](../packages/cordis/repository-plugin/src/index.ts) ## `@deepseek-ai/dsh-sandbox-local` diff --git a/packages/cordis/repository-plugin/src/source.ts b/packages/cordis/repository-plugin/src/source.ts index c0362bf162..3ccddc6c52 100644 --- a/packages/cordis/repository-plugin/src/source.ts +++ b/packages/cordis/repository-plugin/src/source.ts @@ -5,15 +5,23 @@ import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' -import type { Context, Fiber, Plugin } from 'cordis' +import type { Context, Fiber, FiberState, Plugin } from 'cordis' import type { RepositoryCache } from '@cordisjs/plugin-loader/repository' import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { PREPARED_ENTRY_FILENAME } from './format.ts' +// Value mirror: Cordis's const enum has no runtime object to import. Keep +// aligned with `packages/cordis/tool-cordis/src/fiber-state.ts`. +const FIBER_ACTIVE = 2 as FiberState.ACTIVE + /** Directory under the Harness home containing immutable repository generations. */ export const DEFAULT_REPOSITORY_CACHE_DIRECTORY = 'repository-plugins' -const GITHUB_SOURCE_PATTERN = /^github:([^/\s#&]+)\/([^/\s#&]+)#([^\s&]+)(?:&path:(\/[^\s&]+))?$/ +// The ref segment excludes `#` so `github:o/r#a#b` fails here — at the config +// parser, with the syntax the error message promises — instead of inside the +// cache's pnpm install ('misconfiguration fails loud at the earliest +// resolvable point'). +const GITHUB_SOURCE_PATTERN = /^github:([^/\s#&]+)\/([^/\s#&]+)#([^\s#&]+)(?:&path:(\/[^\s&]+))?$/ function validPluginPath(path: string): boolean { const segments = path.split('/').slice(1) @@ -67,6 +75,18 @@ export async function loadPreparedRepository( try { const plugin = await import(/* @vite-ignore */pathToFileURL(filename).href) as Plugin const fiber = ctx.plugin(plugin) + await fiber + // Awaiting a service-gated fiber returns while it is still PENDING (the + // generated wrapper injects `skills`/`tools` per its manifest). This + // runtime commits the repository configuration transactionally, so a + // composition that never provides a required service must reject the + // transaction here — not settle ACTIVE with a silently pending child. + if (fiber.state !== FIBER_ACTIVE) { + const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined) + /* v8 ignore next 2 -- the 'unknown' arm needs a service to appear after the state read; not deterministically stageable. */ + const detail = missing.join(', ') || 'unknown' + throw new Error(`prepared wrapper did not activate (waiting for services: ${detail})`) + } return await fiber } catch (cause) { throw new Error(`failed to load prepared repository Plugin ${JSON.stringify(specifier)} from ${filename}`, { cause }) diff --git a/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts b/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts index 06d0c9d322..39840fd1f3 100644 --- a/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts +++ b/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts @@ -296,6 +296,7 @@ describe('configured GitHub repository sources', () => { for (const source of [ 'github:owner/repository', 'github:owner/repository#', + 'github:owner/repository#a#b', 'https://github.com/owner/repository#ref', 'github:owner/repository#ref&path:relative/.dsh-plugin', ]) { @@ -350,6 +351,52 @@ describe('configured GitHub repository sources', () => { await ctx.fiber.dispose() }) + it('swaps generations on a live source-list update and rolls a failed candidate back', async () => { + // The headline flow: a personal-config edit reaches this plugin as a + // Loader entry.update, which restarts the row's fiber (old cleanup, then + // new apply — so the 'already registered' builtin guard must not fire). + const roots: Record = {} + for (const generation of ['one', 'two'] as const) { + const root = await temporaryDirectory(`live-${generation}`) + await writeSkill(join(root, 'skills'), `live-skill-${generation}`) + const directory = await writePlugin(root, `live-fixture-${generation}`, { skills: ['../skills'] }) + await RepositoryPlugin.prepareDshPlugin(directory) + roots[`github:owner/repository#${generation}&path:/.dsh-plugin`] = directory + } + vi.spyOn(RepositoryCache.prototype, 'resolve').mockImplementation(async (specifier) => { + const directory = roots[specifier] + if (directory === undefined) throw new Error(`unprepared generation ${specifier}`) + return directory + }) + + // Route the row through the Loader builtin table exactly as a config tree + // would; the module itself is the row's plugin. + const ctx2 = new Context() + await ctx2.plugin(Loader) + await ctx2.plugin(SkillService) + ctx2.loader.builtins['repository-plugins'] = RepositoryPlugin + const entryId = await ctx2.loader.create({ + name: 'cordis:repository-plugins', + config: { repositories: ['github:owner/repository#one'] }, + }) + await ctx2.loader.await() + await expect(ctx2.skills.get('live-skill-one')).resolves.toMatchObject({ provider: 'repository:live-fixture-one' }) + + const entry = ctx2.loader.resolve(entryId) + await entry.update({ config: { repositories: ['github:owner/repository#two'] } }) + await ctx2.loader.await() + await expect(ctx2.skills.get('live-skill-one')).resolves.toBeUndefined() + await expect(ctx2.skills.get('live-skill-two')).resolves.toMatchObject({ provider: 'repository:live-fixture-two' }) + + // A failed candidate (unprepared source) rejects the update and the + // transactional Loader restores the previous generation. + await expect(entry.update({ config: { repositories: ['github:owner/repository#missing'] } })) + .rejects.toThrow('unprepared generation') + await ctx2.loader.await() + await expect(ctx2.skills.get('live-skill-two')).resolves.toMatchObject({ provider: 'repository:live-fixture-two' }) + await ctx2.fiber.dispose() + }) + it('rejects duplicate generations and cleans the builtin after cache preparation fails', async () => { const ctx = new Context() await ctx.plugin(Loader) @@ -368,6 +415,28 @@ describe('configured GitHub repository sources', () => { await ctx.fiber.dispose() }) + it('rejects a wrapper left pending by a composition without its required services', async () => { + // A skills-declaring generation mounted where no skills service exists: + // the wrapper fiber stays PENDING, and the transaction must fail loud + // instead of committing an ACTIVE row over a silently inert child. + const root = await temporaryDirectory('pending-services') + await writeSkill(join(root, 'skills'), 'pending-service-skill') + const directory = await writePlugin(root, 'pending-service-fixture', { skills: ['../skills'] }) + await RepositoryPlugin.prepareDshPlugin(directory) + + const ctx = new Context() + await ctx.plugin(Loader) + // Deliberately NO SkillService. + await expect(loadPreparedRepository(ctx, { resolve: async () => directory }, 'github:owner/repository#pending&path:/.dsh-plugin')) + .rejects.toMatchObject({ + message: expect.stringContaining('failed to load prepared repository Plugin') as string, + cause: expect.objectContaining({ + message: expect.stringContaining('waiting for services: skills') as string, + }) as Error, + }) + await ctx.fiber.dispose() + }) + it('labels a missing prepared wrapper with its exact source and path', async () => { const root = await temporaryDirectory('missing-wrapper') const ctx = new Context() diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 26747ef545..15cf4918ad 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -323,8 +323,11 @@ export async function watchPersonalPatches( const entry = bootstrapIncludes.get(ctx) if (entry === undefined) throw new Error(`${binName}: personal config watching requires the root Include entry`) const filename = join(dir, PERSONAL_CONFIG_FILENAME) - const { patches: _initialPatches, ...includeConfig } = entry.options.config as Include.Config return hmr.registerConfig(filename, async () => { + // Re-read the include's non-patch options per refresh: a writer that + // updates the root Include's other options between refreshes (none exists + // today) must not have them silently reverted by a personal reload. + const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config const personalPatches = loadPersonalPatches(binName, dir) ?? [] const patches = compose(personalPatches) await entry.update({ From c96f48e176e826758299d4b90be8d59fb236b48b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:38:13 +0800 Subject: [PATCH 21/23] fix(review): tolerate mid-teardown personal-watcher registration CI caught a startup race the PTY smoke stages naturally: a TUI /exit typed while watchPersonalPatches is still opening its watcher disposes the tree, and the HMR effect registration rejects with INACTIVE_EFFECT, crashing an app that exited exactly as asked. Return a no-op disposer for that exact code; every other registration failure still propagates (covered both ways). --- packages/ui/app-boot/src/index.ts | 12 ++++++- .../ui/app-boot/tests/personal-config.spec.ts | 34 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 15cf4918ad..eb5003f72b 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -323,7 +323,7 @@ export async function watchPersonalPatches( const entry = bootstrapIncludes.get(ctx) if (entry === undefined) throw new Error(`${binName}: personal config watching requires the root Include entry`) const filename = join(dir, PERSONAL_CONFIG_FILENAME) - return hmr.registerConfig(filename, async () => { + const register = hmr.registerConfig(filename, async () => { // Re-read the include's non-patch options per refresh: a writer that // updates the root Include's other options between refreshes (none exists // today) must not have them silently reverted by a personal reload. @@ -337,6 +337,16 @@ export async function watchPersonalPatches( }, }) }) + try { + return await register + } catch (error) { + // A surface can dispose the whole tree while the watcher is still opening + // (a TUI `/exit` typed during startup): the HMR effect registration then + // fails with INACTIVE_EFFECT. That is the app exiting exactly as asked, + // not a watch failure — return a no-op disposer instead of crashing. + if ((error as { code?: string } | null)?.code === 'INACTIVE_EFFECT') return async () => {} + throw error + } } /** diff --git a/packages/ui/app-boot/tests/personal-config.spec.ts b/packages/ui/app-boot/tests/personal-config.spec.ts index 62003599d8..53df1d84b7 100644 --- a/packages/ui/app-boot/tests/personal-config.spec.ts +++ b/packages/ui/app-boot/tests/personal-config.spec.ts @@ -233,4 +233,38 @@ describe('boot with personal patches', () => { await expect(watchPersonalPatches(withoutInclude, { binName: NAME, dir: tmp() })).rejects.toThrow('requires the root Include entry') await withoutInclude.fiber.dispose() }) + + it('returns a no-op disposer when the tree is disposed while the watcher opens', async () => { + // A TUI `/exit` typed during startup disposes the whole tree while + // registerConfig's effect registration is still in flight (the HMR effect + // then fails with INACTIVE_EFFECT); the app is exiting exactly as asked, + // so the watcher must not crash the process. The stub makes the race + // deterministic — the live-teardown ordering itself is not stageable. + const dir = tmp() + const ctx = await boot(NAME, writeTree(dir)) + try { + const teardown = Object.assign(new Error('cannot create effect on inactive context'), { code: 'INACTIVE_EFFECT' }) + ctx.provide('hmr', { registerConfig: () => Promise.reject(teardown) }) + const dispose = await watchPersonalPatches(ctx, { binName: NAME, dir: tmp() }) + await expect(dispose()).resolves.toBeUndefined() + } finally { + await ctx.fiber.dispose() + } + }) + + it('propagates registration failures other than mid-teardown', async () => { + const dir = tmp() + const personal = tmp() + const ctx = await boot(NAME, writeTree(dir)) + try { + await ctx.plugin(Timer) + await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + const dispose = await watchPersonalPatches(ctx, { binName: NAME, dir: personal }) + // Same personal path registered twice: HMR refuses; not a teardown race. + await expect(watchPersonalPatches(ctx, { binName: NAME, dir: personal })).rejects.toThrow('already registered') + await dispose() + } finally { + await ctx.fiber.dispose() + } + }) }) From 23610c6abdc5cc7c8fdb453a946a1dc4d2a40c5e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 02:25:24 +0800 Subject: [PATCH 22/23] fix(goal): emit bare GoalRef in fold lastRef and goal/changed notifications goalChangeRef returned the full GoalSnapshot for every snapshot operation, so foldGoal(...).lastRef and the goal/changed notification ref carried objective, phase, and maxGoalRounds fields instead of the declared GoalRef { id, revision }. Only the clear tombstone was bare. Emit an exact { id, revision } ref for snapshot changes and pin the contract with a regression test covering create/edit/block notifications and the fold. --- packages/goal/goal/src/fold.ts | 4 +++- packages/goal/goal/tests/goal.spec.ts | 21 ++++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/goal/goal/src/fold.ts b/packages/goal/goal/src/fold.ts index 2ea83029cf..ee765aaeab 100644 --- a/packages/goal/goal/src/fold.ts +++ b/packages/goal/goal/src/fold.ts @@ -261,7 +261,9 @@ function validateSnapshotTransition( * @returns stable identity used to reconcile a deferred change with its log event. */ export function goalChangeRef(change: GoalChangeMeta): GoalRef { - return change.operation === 'clear' ? change.cleared : change.goal + return change.operation === 'clear' + ? change.cleared + : { id: change.goal.id, revision: change.goal.revision } } /** diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 7c4b4d9b28..82793618c4 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -11,7 +11,7 @@ import GoalService, { foldGoal, renderGoalChange, } from '@deepseek-ai/dsh-goal' -import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal' +import type { GoalChangeMeta, GoalChanged, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal' type DeferredInjection = UserMessage @@ -381,6 +381,25 @@ describe('GoalService mutations', () => { expect(next.id).not.toBe(goal.id) }) + it('emits bare compare-and-set refs in folded lastRef and goal/changed notifications', async () => { + const { ctx, agent, session } = await harness() + const seen: GoalChanged['ref'][] = [] + ctx.on('goal/changed', (_subject, change) => { seen.push(change.ref) }) + const created = ctx.goals.create(agent, { objective: 'bare refs', maxGoalRounds: 3 }) + const edited = ctx.goals.edit(agent, created, { objective: 'bare refs edited' }) + const blocked = ctx.goals.block(agent, edited, { code: 'bare-blocker', message: 'Bare refs.' }) + // GoalRef is exactly { id, revision }: every notification ref must be bare. + for (const ref of seen) { + expect(Object.keys(ref).sort()).toEqual(['id', 'revision']) + expect(ref).toEqual({ id: created.id, revision: ref.revision }) + } + expect(seen).toHaveLength(3) + // The durable fold's lastRef is the same bare ref, not a full snapshot. + const folded = foldGoal(session.events) + expect(folded.lastRef).toEqual({ id: blocked.id, revision: blocked.revision }) + expect(Object.keys(folded.lastRef as object).sort()).toEqual(['id', 'revision']) + }) + it('keeps per-goal mutation timestamps monotonic when the wall clock moves backward', async () => { vi.useFakeTimers() vi.setSystemTime(100) From 9a07380c230b15fa9c82ed7f667c0458bf1867e1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 03:17:34 +0800 Subject: [PATCH 23/23] adopt native GitHub stack workflow --- ...-incremental-pr-base-retargeting.i18n.yaml | 6 +- ...6-07-26-incremental-pr-base-retargeting.md | 4 +- ...7-26-incremental-pr-base-retargeting.zh.md | 4 +- ...thub-stacks-and-optional-rebases.i18n.yaml | 6 + ...tive-github-stacks-and-optional-rebases.md | 43 ++++++ ...e-github-stacks-and-optional-rebases.zh.md | 43 ++++++ .../skills/dsh-merging-stacked-prs/SKILL.md | 132 ++++++++++++++---- .agents/skills/dsh-pre-push-checks/SKILL.md | 29 +++- AGENTS.md | 4 +- ...sponding-to-pr-review-on-a-stack.i18n.yaml | 6 +- .../responding-to-pr-review-on-a-stack.md | 30 ++-- .../responding-to-pr-review-on-a-stack.zh.md | 28 ++-- 12 files changed, 267 insertions(+), 68 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md create mode 100644 .agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.zh.md diff --git a/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.i18n.yaml index 2e9a5d6402..8bb5febd69 100644 --- a/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.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-26-incremental-pr-base-retargeting.md: e2097ac4c32a926c8c0271df19dbc9796d0ed19d -2026-07-26-incremental-pr-base-retargeting.zh.md: a6c94b66732b6c037fee1b0726b31ecb6f3b48c5 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md +2026-07-26-incremental-pr-base-retargeting.md: b2e644d99877b4214b5a6edb2775b3962e6b7da2 +2026-07-26-incremental-pr-base-retargeting.zh.md: 5014fef644f9907c4d16a9a2a3767d6f55633688 diff --git a/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md b/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md index e2097ac4c3..b2e644d998 100644 --- a/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md +++ b/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md @@ -10,9 +10,9 @@ A PR base can advance while its current tip is being merged into the PR branch. ## Decision -Each observed base tip gets its own merge checkpoint. If the base advances during the work, finish and validate the merge already in progress, commit it, and push it when the task authorizes a push. Only then fetch and merge the newer base in a separate merge commit. Never abandon, amend, rebase, or otherwise rewrite the earlier work. +When merge-forward is chosen, each observed base tip gets its own merge checkpoint. If the base advances during the work, finish and validate the merge already in progress, commit it, and push it when the task authorizes a push. Only then fetch and merge the newer base in a separate merge commit. Do not abandon or rewrite a checkpoint within that merge-forward sequence. -The root [AGENTS.md](../../../../AGENTS.md) states the standing order. The [stacked-PR landing skill](../../../skills/dsh-merging-stacked-prs/SKILL.md) applies it while retargeting dependent PRs, and the [stack review guide](../../../../docs/cookbook/responding-to-pr-review-on-a-stack.md) owns merging fixes down a stack. +The [native-stack and optional-rebase decision](2026-08-02-native-github-stacks-and-optional-rebases.md) also permits a lease-protected rebase for standalone or stacked PRs, including after review. This note owns the merge-forward path only. The [stacked-PR landing skill](../../../skills/dsh-merging-stacked-prs/SKILL.md) selects either history under the root [AGENTS.md](../../../../AGENTS.md), and the [stack review guide](../../../../docs/cookbook/responding-to-pr-review-on-a-stack.md) owns propagating fixes through dependent layers. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.zh.md b/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.zh.md index a6c94b6673..5014fef644 100644 --- a/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.zh.md @@ -10,9 +10,9 @@ Status: implemented ## 决策 -每次观察到的 base 分支顶端提交都保留为独立的合并检查点。如果处理期间 base 分支继续前移,先完成并验证正在进行的合并,再将其提交;任务授权推送时,还要完成推送。完成这些步骤后,才能拉取较新的 base,并通过单独的合并提交将其合入。绝不放弃先前工作,也不通过 amend、rebase 或其他方式重写它。 +选择 merge-forward 时,每次观察到的 base 分支顶端提交都保留为独立的合并检查点。如果处理期间 base 分支继续前移,先完成并验证正在进行的合并,再将其提交;任务授权推送时,还要完成推送。完成这些步骤后,才能拉取较新的 base,并通过单独的合并提交将其合入。在这条 merge-forward 序列中,不得放弃或重写任何检查点。 -根 [AGENTS.md](../../../../AGENTS.md) 规定了这项常设指令。[堆叠 PR 落地 skill(技能)](../../../skills/dsh-merging-stacked-prs/SKILL.md)在调整依赖 PR 的 base 时执行这一规则,[堆叠评审指南](../../../../docs/cookbook/responding-to-pr-review-on-a-stack.md)则负责说明如何将修复沿堆叠向下合并。 +[原生堆叠与可选 rebase 决策](2026-08-02-native-github-stacks-and-optional-rebases.md)也允许独立或堆叠 PR 使用受 lease 保护的 rebase,评审后同样如此。本文只负责 merge-forward 路径。[堆叠 PR 落地 skill(技能)](../../../skills/dsh-merging-stacked-prs/SKILL.md)根据根 [AGENTS.md](../../../../AGENTS.md) 选择其中一种历史更新方式,[堆叠评审指南](../../../../docs/cookbook/responding-to-pr-review-on-a-stack.md)则负责说明如何在依赖层之间传播修复。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.i18n.yaml b/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.i18n.yaml new file mode 100644 index 0000000000..96754a36f3 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.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/process/2026-08-02-native-github-stacks-and-optional-rebases.md +2026-08-02-native-github-stacks-and-optional-rebases.md: a349ed18a27ab006384310e4318f057dbf8873b1 +2026-08-02-native-github-stacks-and-optional-rebases.zh.md: 0205eb475bfe951f8382d61bf19df988027afb13 diff --git a/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md b/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md new file mode 100644 index 0000000000..a349ed18a2 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md @@ -0,0 +1,43 @@ +# Agent Note: Native GitHub stacks and optional PR rebases + +Status: implemented + +English | [中文](2026-08-02-native-github-stacks-and-optional-rebases.zh.md) + +## Problem + +A dependent PR chain represented only by base branches has no official stack identity. Landing it requires manually merging one PR at a time, preserving intermediate branches, retargeting every child, and reconstructing whether the chain survived. GitHub's native stacked-PR feature instead carries the order, applies trunk rules and CI to every layer, and owns bottom-up merges and retargeting. + +A blanket prohibition on rewriting reviewed branches also excludes the native `gh stack` synchronization workflow, whose cascading rebase updates each active layer and publishes it with lease protection. Applying that prohibition only outside stacks would give standalone and stacked PRs inconsistent history choices. + +## Decision + +Every same-repository chain of two or more dependent PRs uses GitHub's official stack object before landing. Live `PullRequest.stack` and `stackEntry.position` fields are authoritative. An unstacked chain whose PRs have one author is linked automatically in bottom-to-top order with `gh stack link`; mixed or unavailable authors require user confirmation. Missing native support and cross-fork chains hard-stop. Existing membership in conflicting stacks or an official order that disagrees with the branch topology requires user direction before any stack is dissolved or rebuilt. + +"Land the stack" merges the complete official stack through `gh stack merge --yes --merge`. A partial landing requires an explicit boundary PR and merges the bottom prefix through that PR. The workflow never falls back to per-PR `gh pr merge` and manual retargeting. A direct native merge is all-or-nothing; a merge queue may process the selected PRs in separate groups, so every selected PR must independently reach `MERGED` before the landing is complete. + +Merge-forward and rebase are both allowed refresh histories for standalone and officially stacked PRs, including after review. A remote history rewrite uses an exact lease or the lease-protected `gh stack` push path and aborts if the remote moved; raw `--force` is forbidden. The [incremental base-retargeting decision](2026-07-26-incremental-pr-base-retargeting.md) remains the owner of the merge-forward option. + +Relevant checks normally run before publication. `gh stack sync` is the explicit exception because it fetches, cascade-rebases, and pushes as one operation: every rewritten layer is validated immediately afterward, and no affected PR merges until that evidence passes. After any rewritten push, current heads, unresolved review threads, approvals, mergeability, and checks are re-audited because earlier commit OIDs and inline anchors may be outdated. + +## Verification + +The [stack landing skill](../../../skills/dsh-merging-stacked-prs/SKILL.md) verifies native support, same-repository branches, live authors, official membership and order, merge range, and final merged state. The [stack review guide](../../../../docs/cookbook/responding-to-pr-review-on-a-stack.md) keeps fixes on their introducing layer and covers both propagation histories. The [pre-push workflow](../../../skills/dsh-pre-push-checks/SKILL.md) owns lease protection and immediate post-sync evidence. + +## Alternatives considered + +**Keep branch chains as the only stack representation.** This preserves the manual procedure but gives GitHub no stack object through which to show order, enforce trunk rules across every layer, or merge a range atomically. + +**Adopt native stacks while forbidding their rebase commands after review.** This keeps commit OIDs stable but disables the official synchronization path when a stack is under active review and leaves standalone PRs under a different policy. + +**Require rebase for every PR refresh.** A linear history is useful, but merge checkpoints remain a valid choice when preserving completed conflict resolution and its recovery point matters more than compact history. + +**Automatically dissolve conflicting stacks.** This would make local branch inference override shared GitHub metadata and could disturb PRs or authors outside the requested chain; merged and queued entries cannot always be removed. + +## Consequences + +- Reviewers and automation receive GitHub's stack map, stack-wide rules, CI, and native merge state. +- A same-author legacy chain becomes official without an extra prompt, while mixed ownership and conflicting metadata retain a human decision boundary. +- Rebases can invalidate commit hashes, approvals, or comment anchors after review, so every rewritten push carries a live review and check audit. +- `gh stack sync` can briefly publish code whose local evidence is pending; the affected PRs remain blocked from merging until immediate post-sync validation passes. +- Merge-forward remains available and preserves completed checkpoints, at the cost of additional merge commits. diff --git a/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.zh.md b/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.zh.md new file mode 100644 index 0000000000..0205eb475b --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.zh.md @@ -0,0 +1,43 @@ +# Agent Note: GitHub 原生堆叠与可选 PR rebase + +Status: implemented + +[English](2026-08-02-native-github-stacks-and-optional-rebases.md) | 中文 + +## 问题 + +仅以 base 分支表示的依赖 PR(Pull Request)链没有官方的堆叠身份。要让它落地,就必须逐个手动合并 PR、保留中间分支、调整每个子 PR 的 base,并重新查证这条链是否仍然完整。GitHub 原生的堆叠 PR 功能则会承载顺序,对每一层应用 trunk 规则和 CI,并负责自底向上的合并与 base 调整。 + +一概禁止改写已评审分支,也会排除原生的 `gh stack` 同步工作流:该工作流通过级联 rebase 更新每个活跃层,并在 lease 保护下发布。如果只在堆叠之外实施这项禁令,就会让独立 PR 和堆叠 PR 面临不一致的历史选择。 + +## 决策 + +同一仓库内由两个或更多个相互依赖的 PR 组成的每条链,在落地前都必须使用 GitHub 的官方 stack 对象。以实时 `PullRequest.stack` 和 `stackEntry.position` 字段为权威依据。对于尚未形成官方堆叠且所有 PR 作者相同的链,系统使用 `gh stack link` 按自底向上的顺序自动关联;作者不一或作者信息不可用时,必须取得用户确认。缺少原生支持或跨 fork 的链会使流程硬性停止。如果现有成员属于相互冲突的堆叠,或者官方顺序与分支拓扑不一致,则在解散或重建任何堆叠之前都必须取得用户指示。 + +「落地堆叠」通过 `gh stack merge --yes --merge` 合并整个官方堆叠。部分落地需要明确指定边界 PR,并合并从底部到该 PR 的前缀。工作流绝不回退到逐个执行 `gh pr merge` 和手动调整 base。原生直接合并要么全部成功,要么全部不合并;合并队列可能分组处理所选 PR,因此只有每个所选 PR 都分别达到 `MERGED`,落地才算完成。 + +merge-forward 和 rebase 都可以作为独立 PR 与官方堆叠 PR 的历史刷新方式,包括评审后。改写远端历史时,必须使用精确 lease 或受 lease 保护的 `gh stack` 推送路径;如果远端已经前移,操作必须中止。禁止直接使用 `--force`。[增量更新 base 的决策](2026-07-26-incremental-pr-base-retargeting.md)仍负责 merge-forward 选项。 + +相关检查通常在发布前运行。`gh stack sync` 是明确的例外,因为它在一次操作中完成获取、级联 rebase 和推送:随后立即验证每个已改写的层;这些验证通过前,不得合并任何受影响的 PR。每次改写推送后,都要重新审计当前 head、未解决的评审线程、批准状态、可合并性和检查结果,因为先前的 commit OID 和内联锚点可能已经过时。 + +## 验证 + +[堆叠落地 skill(技能)](../../../skills/dsh-merging-stacked-prs/SKILL.md)验证原生支持、同仓库分支、实时作者信息、官方成员关系与顺序、合并范围以及最终合并状态。[堆叠评审指南](../../../../docs/cookbook/responding-to-pr-review-on-a-stack.md)让修复留在引入问题的层,并涵盖两种用于传播修复的历史策略。[推送前工作流](../../../skills/dsh-pre-push-checks/SKILL.md)负责 lease 保护和同步后立即验证所得的证据。 + +## 曾考虑的替代方案 + +**仅以分支链表示堆叠。** 这种做法保留手动流程,但 GitHub 没有 stack 对象可用于展示顺序、对每一层执行 trunk 规则或以原子操作合并整个范围。 + +**采用原生堆叠,但禁止在评审后使用其 rebase 命令。** 这会保持 commit OID 稳定,但也会在堆叠正在接受评审时禁用官方同步路径,并让独立 PR 遵循不同的政策。 + +**要求每次刷新 PR 都使用 rebase。** 线性历史很有价值,但当保存已经完成的冲突解决及其恢复点比紧凑历史更重要时,合并检查点仍然是有效选择。 + +**自动解散相互冲突的堆叠。** 这会让本地分支推断凌驾于共享的 GitHub 元数据之上,并可能干扰所请求链之外的 PR 或作者;已经合并或进入队列的条目不一定都能移除。 + +## 后果 + +- 评审者和自动化会获得 GitHub 的堆叠图、覆盖整个堆叠的规则、CI 和原生合并状态。 +- 同一作者的遗留链无需额外询问即可成为官方堆叠;链由多名作者共同拥有或元数据发生冲突时,仍保留人工决策边界。 +- 评审后,rebase 可能使 commit hash、批准状态或评论锚点失效,因此每次改写推送后都要对实时评审状态和检查结果进行审计。 +- `gh stack sync` 可能短暂发布本地验证仍待完成的代码;受影响的 PR 在同步后立即验证通过前仍禁止合并。 +- merge-forward 仍然可用,并以增加合并提交为代价保留已完成的检查点。 diff --git a/.agents/skills/dsh-merging-stacked-prs/SKILL.md b/.agents/skills/dsh-merging-stacked-prs/SKILL.md index 50ceda2233..4bcb568955 100644 --- a/.agents/skills/dsh-merging-stacked-prs/SKILL.md +++ b/.agents/skills/dsh-merging-stacked-prs/SKILL.md @@ -1,53 +1,127 @@ --- name: dsh-merging-stacked-prs -description: Use when landing a stack of dependent GitHub PRs (A ← B ← C, where each bases on the one below) onto master — merging more than one PR in a chain, merging a PR whose base is another open PR's branch, or whenever a request mentions "stacked PRs", "PR stack", "dependent PRs", "base branch", or merging several related PRs in sequence. Critical because deleting a base branch mid-chain auto-closes the open PR that bases on it — get the order wrong and you silently close unmerged work. +description: Use when landing a stack of dependent GitHub PRs (A ← B ← C, where each bases on the one below) onto master, merging a PR whose base is another open PR's branch, or whenever a request mentions "stacked PRs", "PR stack", "dependent PRs", or merging several related PRs in sequence. Requires every same-repository dependency chain to use GitHub's official stacked-PR feature before landing so GitHub owns stack-wide rules, CI, ordering, retargeting, and merge state. --- -# Merging a stacked PR chain +# Landing an official GitHub PR stack -This skill is the landing procedure for a dependent PR stack. The standing orders it rests on — merge commits only (`gh pr merge --merge`), never rewrite a pushed branch — live in the root [AGENTS.md](../../../AGENTS.md) § Conventions; the discipline for handling review comments across a stack before it lands is the [responding-to-pr-review-on-a-stack](../../../docs/cookbook/responding-to-pr-review-on-a-stack.md) cookbook guide. +Land dependent PRs through GitHub's native stack object and `gh stack merge`. Do not reproduce stack semantics by merging and retargeting individual PRs with `gh pr merge` and `gh pr edit`. The root [AGENTS.md](../../../AGENTS.md) owns the allowed merge-forward and rebase histories; the [stack review guide](../../../docs/cookbook/responding-to-pr-review-on-a-stack.md) owns review-fix propagation. -## The hazard this prevents +## Require native stack support -On GitHub, **deleting a PR's base branch auto-closes that PR.** In a stack `A ← B ← C` (B bases on A, C bases on B), branch A is the base of PR B, and branch B is the base of PR C. So if you merge A with `--delete-branch`, GitHub closes PR B before it's merged — silently destroying the chain. The whole procedure below exists to avoid that: **merge one at a time, retarget each dependent as you go, and delete nothing until every PR has landed.** +Run `gh stack --version` before changing GitHub state. Hard-stop if the official extension or server-side stack feature is unavailable; do not fall back to the legacy manual landing procedure. GitHub stacks require every head branch to live in the same repository, so hard-stop on a cross-fork chain. -## The procedure +Use a clean dedicated worktree. Fetch current PR metadata and exact head OIDs rather than trusting branch names or an earlier report: -Given `A ← B ← C` landing on `master`: +```sh +gh pr view --json number,author,baseRefName,baseRefOid,headRefName,headRefOid,isCrossRepository,state,isDraft,reviewDecision,mergeStateStatus,statusCheckRollup +``` -1. **Merge PR A into master, keeping its branch.** `gh pr merge A --merge` — no `--delete-branch`. Branch A must survive because PR B still bases on it. Before touching the next link, confirm the merge actually landed: with required checks pending or a merge queue, `gh pr merge` may only enable auto-merge and return early, so wait until `gh pr view A --json state` reports `MERGED`. This applies after every merge in the stack. +Query `PullRequest.stack` and `stackEntry.position` for at least one PR in each apparent chain; this official GitHub object, not base-branch inference alone, is the stack-membership authority. Paginate `entries` when `size` exceeds the returned page: -2. **Retarget PR B, refresh it, then merge it — keeping its branch.** - - `gh pr edit B --base master` (now that A is in master, B's base becomes master). - - Merge the new master *into* branch B: check out B, `git fetch origin`, `git merge origin/master` — merge `origin/master`, not local `master`, because `gh pr merge` updated only GitHub and the local branch is stale — resolve any conflicts here, and push. This makes B current and surfaces conflicts in the working branch where they can be tested — not as a surprise at the GitHub merge. - - If `origin/master` moves during that work, finish and push the in-progress merge, then fetch and merge the newer tip in a separate commit. Never abandon or rewrite the earlier work ([rationale](../../notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md)). - - `gh pr merge B --merge` — still no `--delete-branch` (PR C bases on branch B). +```sh +gh api graphql -F owner= -F name= -F number= -f query=' +query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + number + author { login } + baseRefName + headRefName + stackEntry { position } + stack { + number + baseRefName + size + entries(first: 100) { + nodes { + position + pullRequest { number author { login } baseRefName headRefName state isDraft } + } + } + } + } + } +}' +``` -3. **Retarget PR C, refresh it, then merge it — keeping its branch.** Same steps: `gh pr edit C --base master`, fetch and merge `origin/master` into branch C, resolve conflicts there and push, then `gh pr merge C --merge` without `--delete-branch`. +Establish the expected bottom-to-top order from the live PR bases: the bottom targets the trunk, and each higher PR targets the head branch immediately below it. -4. **Only after every PR (A, B, C) is merged, delete the branches** — local and remote, for all of A, B, C. +## Link missing stack members -## Why "merge new master into the dependent before merging it" +First compare any existing stack entries with the expected chain. One existing stack may contain an order-preserving subset of the requested chain; multiple stack numbers, an unexpected entry, or a conflicting order requires user direction before any mutation. -Each retarget step merges the freshly-updated master back into the dependent branch *before* merging the PR. This keeps each PR's diff clean (it only shows that PR's own changes, not the parent's) and forces conflicts to surface in the working branch, where you can build and test the resolution — instead of letting GitHub attempt a blind merge that may conflict or quietly mis-resolve. +When any dependent PR is not yet in that official stack: -## Verify before deleting anything +1. Compare every `author.login` exactly. +2. If all authors match, link the chain automatically in bottom-to-top order: -Before deleting a branch, ask GitHub directly whether any open PR still bases on it: +```sh +gh stack link --base ... +``` + +3. If authors differ or any author is unavailable, ask the user whether to link before changing GitHub state. +4. Re-query GraphQL and require one stack number, the expected trunk, the complete PR set, and the expected positions and base chain. + +Never dissolve, reorder, or rebuild an existing stack automatically; `gh stack link` is additive and merged or queued entries cannot be unstacked. + +## Refresh only when needed + +Do not rewrite branches merely because a refresh mechanism exists. When the live merge state or repository rules require an updated trunk, choose either allowed history: + +- **Native cascading rebase:** check out the remote stack with `gh stack checkout ` when it is not tracked locally, then run `gh stack sync`. The command may rebase and lease-protected force-push every active layer before local validation. Immediately inspect the rewritten scope, run the relevant checks for every affected layer, and do not merge or claim readiness until they pass. If sync detects a rebase conflict, use `gh stack rebase`, resolve and validate it, then publish with `gh stack push`. If checkout or sync reports divergent local and remote stack compositions, cancel and ask rather than deleting or recreating the remote stack automatically. +- **Incremental merge-forward:** merge the trunk into the bottom affected branch, then propagate each updated parent into its child in bottom-to-top order and push normally. If the base advances during an in-progress merge, preserve that checkpoint before merging the newer tip as specified by the [incremental-retargeting note](../../notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md). + +Any history rewrite is allowed after review, but it invalidates commit-OID assumptions. Re-fetch exact heads and re-audit unresolved review threads, approvals, mergeability, and checks after the push. Never use raw `--force` or overwrite a concurrently advanced remote head. + +## Preflight the merge range + +Re-query the official stack immediately before merging. Require every selected PR to be open, non-draft, in the expected order, and compliant with the repository's review and check requirements. Treat each PR's state independently; a ready top layer does not prove its dependencies are ready. + +"Land the stack" selects the whole stack. A partial landing requires an explicit boundary PR and includes every layer from the bottom through that boundary. + +## Merge through the stack API + +Merge the whole stack by its official stack number: + +```sh +gh stack merge --yes --merge +``` + +For an explicitly requested partial landing, merge through the boundary PR: + +```sh +gh stack merge --yes --merge +``` + +Do not pass `--delete-branch`, manually retarget dependents, or issue per-PR merge commands. GitHub merges the selected range bottom-up and retargets/rebases any remaining upper layers. A direct stack merge is all-or-nothing; when the trunk uses a merge queue, GitHub queues the selected range together but may land it in separate groups. + +Do not bypass merge requirements. If the native merge reports a blocker, inspect and resolve that blocker through the owning PR or stop and report it; never fall back to `gh pr merge`. + +## Verify the landed state + +Wait for every selected PR to report `MERGED`; a queued request is not a completed landing: + +```sh +gh pr view --json number,state,mergedAt,mergeCommit,baseRefName,headRefName +``` + +For a partial landing, re-query the official stack and verify that every remaining PR is still linked in the expected order and targets the stack trunk or the layer below it. Re-check current heads, review state, and CI because GitHub may have rebased the remaining layers. + +Delete branches only in a separate final pass after the corresponding PRs report `MERGED`. Before deleting each branch, require GitHub to report no open PR still using it as a base: ```sh gh pr list --state open --base --json number --jq length ``` -Anything other than `0` means open PRs still base on `` and deleting it would auto-close them — do not delete it. The `--base` filter is applied server-side, so zero-versus-non-zero is exact no matter how many PRs are open; the printed number itself saturates at `gh`'s `--limit` (default 30), which never matters here because only `0` clears a delete. Default to merging without `--delete-branch` throughout, and do the deletions as a separate final pass once every branch you're about to delete reports `0`. +Anything other than `0` blocks deletion. -## Longer chains +## Checklist -The pattern extends to any depth. For `A ← B ← C ← D ← …`, walk the stack from the bottom up: merge the lowest, then for each next link retarget to master, fetch and merge `origin/master` into it, merge the PR — always without deleting — and only sweep up all the branches at the very end. The invariant never changes: **a branch may be deleted only when no open PR bases on it.** - -## Quick checklist - -- [ ] Merge bottom PR first, `--merge`, no `--delete-branch`; wait until `gh pr view --json state` shows `MERGED`. -- [ ] For each dependent: `gh pr edit --base master` → fetch and merge `origin/master` into the branch (resolve conflicts there, push) → `gh pr merge --merge`, no `--delete-branch`; again wait for `MERGED`. -- [ ] Before each branch delete: `gh pr list --state open --base --json number --jq length` prints `0`. -- [ ] Delete all branches (local + remote) only as a final pass. +- [ ] Native `gh stack` support is available; every PR branch is in the same repository. +- [ ] Live PR bases and exact heads establish one bottom-to-top dependency chain. +- [ ] GraphQL reports one official stack with the expected trunk, entries, and order; an eligible same-author unstacked chain was linked automatically. +- [ ] Any rewritten layers passed relevant validation, and review threads, approvals, mergeability, and checks were re-audited afterward. +- [ ] The whole stack, or an explicitly bounded prefix, was submitted through `gh stack merge --yes --merge`. +- [ ] Every selected PR reports `MERGED`; any remaining upper layers still form the expected official stack. +- [ ] Branch deletion happened only after merged-state and zero-dependent verification. diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index fe5de961a9..dd04cf9b33 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -1,11 +1,11 @@ --- name: dsh-pre-push-checks -description: Use before pushing, force-pushing, marking ready for review, or claiming checks pass on a deepseek-harness branch to select the smallest tests and checks that cover the outgoing diff without reflexively running the full repository suite. +description: Use before pushing, force-pushing, marking ready for review, or claiming checks pass on a deepseek-harness branch, and immediately after gh stack sync publishes rewritten branches, to select the smallest tests and checks that cover the outgoing or just-published diff without reflexively running the full repository suite. --- # DSH Pre-Push Checks -Use this skill to run relevant local evidence once before a `deepseek-harness` push. Git hooks are intentionally narrow: pre-commit fixes staged lint, checks staged whitespace, and guards vendored-source metadata; pre-push runs only the incremental repository typecheck. CI owns exhaustive coverage and the platform matrix. +Use this skill to run relevant local evidence once before a `deepseek-harness` push. The sole ordering exception is `gh stack sync`, which may publish a cascading rebase before the rewritten layers can be validated; validate them immediately afterward and do not merge until the evidence passes. Git hooks are intentionally narrow: pre-commit fixes staged lint, checks staged whitespace, and guards vendored-source metadata; pre-push runs only the incremental repository typecheck. CI owns exhaustive coverage and the platform matrix. ## Inspect the outgoing change @@ -63,9 +63,26 @@ pnpm exec vitest related packages///src/.ts \ Run the complete local approximation only when the user explicitly requests it, while diagnosing a CI failure, or when the change spans the repository so broadly that no narrower set is credible. Use the current workflow and package scripts as the inventory; do not recreate the removed `check:pre-push` aggregate. +## Protect history-rewriting pushes + +Rebase is allowed for standalone and stacked PR branches, including after review. Before a standalone history rewrite, fetch the current remote branch and record its exact OID; publish with `--force-with-lease=:` so a concurrent update aborts the push. `gh stack push` and `gh stack sync` supply lease protection for their managed branches. Raw `--force` is never allowed. + +After any rewritten push, fetch the live heads again and re-audit unresolved review threads, approvals, mergeability, and checks. Commit hashes and inline-comment anchors from before the rewrite are not current evidence. + +### Post-sync validation + +`gh stack sync` fetches, cascade-rebases, and pushes as one operation, so it cannot place local validation between rewrite and publication. Before running it, require a clean worktree and record the official stack order and exact remote heads. After it returns: + +1. Re-query every branch head and the official GitHub stack order. +2. Inspect the changed scope of every rewritten layer against its live PR base. +3. Run the relevant evidence selected by this skill for each affected layer. +4. Keep every PR unmerged and report validation as pending until all selected checks pass. + +If post-sync evidence fails, leave the lease-protected published heads in place, repair the failure, validate the repair, and publish the correction. Do not claim the sync made the stack ready merely because the command succeeded. + ## Handle failures -If a relevant check fails, stop and fix or explain the blocker. Do not push and hope CI differs. +If a relevant check fails before an ordinary push, stop and fix or explain the blocker. Do not push and hope CI differs. For the post-sync exception, block the merge and follow the repair procedure above. If a failure looks environment-specific, prove it: @@ -76,9 +93,11 @@ If a failure looks environment-specific, prove it: ## Push procedure +For ordinary and standalone rebase pushes: + 1. Run the selected relevant checks once. 2. Commit normally and inspect any files changed by the pre-commit fixer before continuing. -3. Push normally so the incremental typecheck hook runs. +3. Push normally, or use the exact lease for an authorized rewritten branch, so the incremental typecheck hook runs. 4. Verify the remote ref matches local `HEAD`. ```sh @@ -92,3 +111,5 @@ gh pr checks ``` Report pending checks as pending. Inspect failures before attributing them to the branch or the environment. + +For `gh stack sync`, use the post-sync validation sequence instead of pretending the ordinary order was possible. diff --git a/AGENTS.md b/AGENTS.md index 9667432285..b7128ff89d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,7 @@ When required `gh`, `pnpm`, build, test, or generator commands fail because the ### Run relevant checks locally -Agents MUST run relevant tests and checks before pushing; select them with [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md) and report only commands run. +Run checks before pushes via [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md); report only commands run. After `gh stack sync`, validate immediately; do not merge before checks pass. - Match evidence to the surface: focused tests for behavior, snapshots for model or user output, `doc-sync` for docs, build/hygiene and built smokes for published paths, and real-API e2e for provider behavior. - Never default to the full suite or repeat a passing check for commit or push. CI owns exhaustive coverage and the platform matrix; rehearse all locally only by explicit request, for CI diagnosis, or for an irreducibly repository-wide change. @@ -116,7 +116,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. -- **Use incremental merge commits.** Split independent changes. Pushed history may be rewritten before review; afterward prefer new commits. Fix the introducing PR before merging down-stack. If the base advances mid-merge, finish the checkpoint, push when authorized, then merge the newer tip separately ([rationale](.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md)). +- **Choose PR history deliberately.** Split independent changes; fix the introducing PR before propagation. Standalone PRs and official stacks may merge-forward or rebase after review. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; an in-progress merge-forward preserves its checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)). - **Label PRs:** one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), each matching area; the [taxonomy](.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md) is extensible. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. diff --git a/docs/cookbook/responding-to-pr-review-on-a-stack.i18n.yaml b/docs/cookbook/responding-to-pr-review-on-a-stack.i18n.yaml index d75b8cad3e..86a72161b2 100644 --- a/docs/cookbook/responding-to-pr-review-on-a-stack.i18n.yaml +++ b/docs/cookbook/responding-to-pr-review-on-a-stack.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 -responding-to-pr-review-on-a-stack.md: 3fb7eb943eeb8d703303be3f6a844870cc26fd47 -responding-to-pr-review-on-a-stack.zh.md: d96323b853c093265931904c20996df335f82926 +# pnpm run verify-translation-pairing --write docs/cookbook/responding-to-pr-review-on-a-stack.md +responding-to-pr-review-on-a-stack.md: 6bb7be3daf8613666f02f21e96bf7b1ac8cb3606 +responding-to-pr-review-on-a-stack.zh.md: 94c2bd2cdd03b9eeca1e180b09bb71471db91c0a diff --git a/docs/cookbook/responding-to-pr-review-on-a-stack.md b/docs/cookbook/responding-to-pr-review-on-a-stack.md index 3fb7eb943e..6bb7be3daf 100644 --- a/docs/cookbook/responding-to-pr-review-on-a-stack.md +++ b/docs/cookbook/responding-to-pr-review-on-a-stack.md @@ -2,25 +2,31 @@ English | [中文](responding-to-pr-review-on-a-stack.zh.md) -Review comments may target several PRs in a dependent stack (`A ← B ← C …`). This guide explains how to resolve them without corrupting the stack. The two invariants it rests on are standing orders in the root [AGENTS.md](../../AGENTS.md) § Conventions: merge commits only, and never rewrite a pushed branch. +Review comments may target several PRs in a dependent stack (`A ← B ← C …`). Keep that chain linked through GitHub's official stacked-PR feature. This guide owns review-fix placement and propagation; the [dsh-merging-stacked-prs](../../.agents/skills/dsh-merging-stacked-prs/SKILL.md) skill owns linkage checks and landing. ## Ground rules 1. **One worktree per PR branch.** Each PR's fixes happen in that PR's own worktree; parallel fixes never share a checkout. -2. **Bring a child up to date by merging the parent down** (`git merge ` into the child, a new merge commit). Never rebase/amend/force-push a pushed branch: rewriting diverges it from what the parent PR and GitHub recorded, breaks the stacked-merge graph, and erases the review-fix history. -3. **A fix lands on the PR that INTRODUCED the issue, then flows down.** When a comment on PR `B` points at code `B` introduced, fix it on `B` and merge `B` into `C` — even if `C` also carries the file. Originating the fix downstream leaves `B` shipping the unfixed code and hides the fix from `B`'s reviewer. -4. **Each review fix is a separate commit, never an amend.** The "fix review findings" commit documents what the review caught. Amending is fine only for your own not-yet-pushed, not-yet-reviewed work. +2. **GitHub's stack object is authoritative.** Base branches establish the expected dependency order, while `PullRequest.stack` and `stackEntry.position` prove that GitHub recognizes it. Do not treat a matching branch chain as an official stack without checking those fields. +3. **A fix lands on the PR that INTRODUCED the issue, then flows up-stack.** When a comment on PR `B` points at code `B` introduced, fix it on `B` and propagate `B` into `C` — even if `C` also carries the file. Originating the fix downstream leaves `B` shipping the unfixed code and hides the fix from `B`'s reviewer. +4. **Each review fix remains a distinct commit.** A later rebase may change its OID, but do not amend a reviewed fix out of the branch history. Amend only your own not-yet-pushed, not-yet-reviewed work. +5. **Choose merge-forward or rebase deliberately.** Both histories are allowed after review. A rewritten push must be lease-protected and must abort rather than overwrite a concurrently advanced remote head; raw `--force` is forbidden. ## Resolve comments through the stack -1. Triage every comment on the merits before acting: verify the claim against the code — a reviewer flagging the right symptom can still mis-diagnose the cause. -2. Map each accepted finding to its originating PR, fix it there, then merge down the chain in order. -3. Delegated fixes are trust-but-verify: a sub-agent's report describes intent, not necessarily what landed. Re-run the gates yourself on the actual tree, and for a regression guard, prove it FAILS on the unfixed code (introduce the regression, watch red, revert) — a guard that passes both ways guards nothing. A sub-agent that reframes a problem as already-handled is a signal to dig in personally. -4. Reply in the review thread (`gh api repos/{owner}/{repo}/pulls/{pr}/comments/{id}/replies`), not as a top-level comment, stating the fix and the commit that carries it. -5. Before merging the stack, check dependents: deleting a PR's base branch auto-closes the dependent PR — check each branch with `gh pr list --state open --base --json number --jq length` (non-zero = open dependents), and merge without `--delete-branch` where a child still bases on the branch. The full landing procedure is the [dsh-merging-stacked-prs](../../.agents/skills/dsh-merging-stacked-prs/SKILL.md) skill. +1. Triage every comment on the merits before acting: verify the claim against the code — a reviewer flagging the right symptom can still misdiagnose the cause. +2. Map each accepted finding to its originating PR and fix it there. +3. Propagate the fixed layer through every affected child in order: + - **Merge-forward:** merge the fixed parent branch into its child, validate the child, and continue upward. Preserve each in-progress checkpoint under the [incremental-retargeting decision](../../.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md). + - **Native cascading rebase:** use `gh stack rebase`, validate the rewritten layers, then publish with `gh stack push`; or use `gh stack sync`, which may publish first and therefore requires immediate post-sync validation under [dsh-pre-push-checks](../../.agents/skills/dsh-pre-push-checks/SKILL.md). +4. Treat delegated fixes as trust-but-verify: a sub-agent's report describes intent, not necessarily what landed. Re-run the gates yourself on the actual tree, and for a regression guard, prove it FAILS on the unfixed code (introduce the regression, watch red, revert) — a guard that passes both ways guards nothing. A sub-agent that reframes a problem as already handled is a signal to dig in personally. +5. Reply in the review thread (`gh api repos/{owner}/{repo}/pulls/{pr}/comments/{id}/replies`), not as a top-level comment, stating the fix and the current commit or head that carries it. +6. After any rewritten push, re-read unresolved threads, approvals, mergeability, and checks. A force-pushed commit OID or outdated inline anchor is not current evidence that the finding remains resolved. +7. Land only through the official stack procedure. If the PRs are not yet linked, the landing skill automatically links a same-author chain, asks before linking mixed authors, and hard-stops when native stack support is unavailable. ## Verify -- Every fixed PR shows a new commit (no force-push icon in the PR timeline). -- Each child PR's diff against its parent still shows only its own changes. -- The gates pass on every PR in the stack, not just the top. +- Every fixed PR's current diff contains the intended correction at the layer that introduced the issue. +- GraphQL reports one official stack in the expected order, and each child diff against its parent shows only that child's changes. +- Unresolved threads, approvals, mergeability, and checks were re-audited after every rewritten push. +- The relevant gates pass on every affected PR in the stack, not just the top. diff --git a/docs/cookbook/responding-to-pr-review-on-a-stack.zh.md b/docs/cookbook/responding-to-pr-review-on-a-stack.zh.md index d96323b853..94c2bd2cdd 100644 --- a/docs/cookbook/responding-to-pr-review-on-a-stack.zh.md +++ b/docs/cookbook/responding-to-pr-review-on-a-stack.zh.md @@ -2,25 +2,31 @@ [English](responding-to-pr-review-on-a-stack.md) | 中文 -评审意见可能同时针对一条依赖堆叠(`A ← B ← C …`)中的多个 PR(Pull Request)。本指南说明如何在不破坏堆叠的前提下解决这些意见。它依赖的两个不变式是根 [AGENTS.md](../../AGENTS.md) § Conventions 中的常设指令:只用 merge commit,以及永远不改写已推送的分支。 +评审意见可能同时针对一条依赖堆叠(`A ← B ← C …`)中的多个 PR(Pull Request)。请通过 GitHub 官方的堆叠 PR 功能保持这条链的关联。本指南负责评审修复的归属与传播;[dsh-merging-stacked-prs](../../.agents/skills/dsh-merging-stacked-prs/SKILL.md) skill(技能)负责检查关联关系和落地。 ## 基本规则 1. **每个 PR 分支一个 worktree。** 每个 PR 的修复在该 PR 自己的 worktree 中进行;并行修复绝不共享同一个 checkout。 -2. **通过将父分支向下合并来更新子分支**(在子分支中执行 `git merge `,产生一个新的 merge commit)。绝不对已推送的分支做 rebase/amend/force-push:改写会使分支与父 PR 及 GitHub 记录的内容产生分歧,破坏堆叠合并图,并抹去评审修复历史。 -3. **修复落在引入问题的那个 PR 上,然后向下流动。** 当 PR `B` 上的评论指向 `B` 引入的代码时,在 `B` 上修复,再将 `B` 合并到 `C`——即使 `C` 也包含该文件。把修复发起在下游会导致 `B` 带着未修复的代码交付,并对 `B` 的评审者隐藏修复。 -4. **每个评审修复是一个独立 commit,绝不 amend。** "修复评审发现"的 commit 记录了评审捕获的内容。只有你自己尚未推送、尚未评审的工作才可以 amend。 +2. **GitHub 的 stack 对象是权威依据。** base 分支确定预期的依赖顺序,`PullRequest.stack` 和 `stackEntry.position` 则证明 GitHub 已识别该堆叠。未经检查这些字段,不得仅凭分支链吻合就将其视为官方堆叠。 +3. **修复落在引入问题的那个 PR 上,然后沿堆叠向上流动。** 当 PR `B` 上的评论指向 `B` 引入的代码时,在 `B` 上修复,再将 `B` 的变更传播到 `C`,即使 `C` 也包含该文件。把修复发起在下游会导致 `B` 带着未修复的代码交付,并对 `B` 的评审者隐藏修复。 +4. **每项评审修复都保留为独立 commit。** 后续 rebase 可能改变其 OID,但不得通过 amend 把已经评审的修复从分支历史中抹去。只有你自己尚未推送且尚未评审的工作才可以 amend。 +5. **明确选择 merge-forward 或 rebase。** 评审后允许采用这两种历史更新方式。改写历史的推送必须受 lease 保护;如果远端 head 在此期间前移,操作必须中止,不得将其覆盖。禁止直接使用 `--force`。 ## 沿堆叠解决评审意见 1. 在行动之前先就事论事地审视每条评论:对照代码验证其论断——评审者指出了正确的症状,但仍可能误诊原因。 -2. 将每个被接受的发现映射到其发起 PR,在那里修复,然后按顺序沿链向下合并。 -3. 委派的修复需要信任但验证:子 agent(智能体)的报告描述的是意图,不一定是实际落地的内容。请亲自在实际代码树上重新运行门禁;对于回归守卫,要证明它在未修复的代码上**失败**(引入回归、观察变红、再还原)——两种情况都通过的守卫什么也守不住。子 agent 将问题重新定性为「已处理」时,这是一个需要亲自深入的信号。 -4. 在评审线程中回复(`gh api repos/{owner}/{repo}/pulls/{pr}/comments/{id}/replies`),而非发顶层评论;说明修复内容及承载修复的 commit。 -5. 合并堆叠之前,检查依赖方:删除一个 PR 的 base 分支会自动关闭依赖它的 PR。用 `gh pr list --state open --base --json number --jq length` 检查每个分支(非零 = 有打开的依赖方),当子 PR 仍以该分支为 base 时,合并时不带 `--delete-branch`。完整的落地流程见 [dsh-merging-stacked-prs](../../.agents/skills/dsh-merging-stacked-prs/SKILL.md) skill(技能)。 +2. 将每个被接受的发现映射到其发起 PR,并在那里修复。 +3. 将修复后的层按顺序传播到每个受影响的子 PR: + - **Merge-forward:** 将修复后的父分支合并到其子分支,验证子分支,然后继续沿堆叠向上传播。依照[增量更新 base 的决策](../../.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md),保留每个正在处理的检查点。 + - **原生级联 rebase:** 使用 `gh stack rebase`,验证所有已改写的层,然后通过 `gh stack push` 发布;也可以使用 `gh stack sync`,该命令可能先发布,因此必须按照 [dsh-pre-push-checks](../../.agents/skills/dsh-pre-push-checks/SKILL.md) 在同步后立即验证。 +4. 委派的修复需要信任但验证:子 agent(智能体)的报告描述的是意图,不一定是实际落地的内容。请亲自在实际代码树上重新运行门禁;对于回归守卫,要证明它在未修复的代码上**失败**(引入回归、观察变红、再还原)——两种情况都通过的守卫什么也守不住。子 agent 将问题重新定性为「已处理」时,这是一个需要亲自深入的信号。 +5. 在评审线程中回复(`gh api repos/{owner}/{repo}/pulls/{pr}/comments/{id}/replies`),而非发顶层评论;说明修复内容及当前承载修复的 commit 或 head。 +6. 每次改写推送后,都要重新读取未解决线程、批准状态、可合并性和检查结果。经 force-push 改写的 commit OID 或已过时的内联锚点,都不足以证明该发现当前仍处于已解决状态。 +7. 仅可通过官方堆叠流程落地。如果这些 PR 尚未关联,落地 skill 会自动关联作者相同的链;如果作者不同,则先询问用户;如果原生堆叠支持不可用,则硬性停止流程。 ## 验证 -- 每个已修复的 PR 显示一个新 commit(PR 时间线中没有 force-push 图标)。 -- 每个子 PR 相对其父 PR 的 diff 仍然只包含自身的变更。 -- 门禁在堆叠中的每个 PR 上都通过,而不仅仅是顶部。 +- 每个已修复 PR 的当前 diff 都在引入问题的那一层包含预期修正。 +- GraphQL 报告的官方堆叠只有一个且顺序符合预期;每个子 PR 相对于父 PR 的 diff 只显示该子 PR 自身的变更。 +- 每次改写推送后,均重新审计了未解决线程、批准状态、可合并性和检查结果。 +- 相关门禁在堆叠中的每个受影响 PR 上都通过,而不仅仅是顶部。